Skip to content

Commit 294f496

Browse files
committed
complete icon support
1 parent 9d58dad commit 294f496

File tree

7 files changed

+170
-58
lines changed

7 files changed

+170
-58
lines changed

qbpm.1.scd

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,18 @@ $HOME/.local/share), but this can be set to another directory by passing
7777
qbpm launch -n qb-dev --debug --json-logging
7878
```
7979

80+
*icon* [options] <profile> <icon>
81+
Install an icon to _profile_. _icon_ may be a url, a path to an image file,
82+
or, if --by-name is passed, the name of an xdg icon installed on your
83+
system. If _icon_ is a url, qbpm will fetch the page and attempt to find a
84+
suitable favicon.
85+
86+
Options:
87+
88+
*-n, --by-name*
89+
Interpret _icon_ as the name of an xdg icon file according to the
90+
freedesktop.org icon specification. Likely to only work on Linux.
91+
8092
*choose* [options]
8193
Open a menu to choose a qutebrowser profile to launch. On linux this defaults
8294
to dmenu or another compatible menu program such as rofi, and on macOS this

qbpm/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# TODO real config file
2+
default_icon = "qutebrowser"
3+
application_name_suffix = " (qutebrowser profile)"

qbpm/icons.py

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import re
12
import shutil
23
from collections import namedtuple
4+
from collections.abc import Iterator
35
from pathlib import Path
46
from tempfile import TemporaryDirectory
57
from typing import Optional
@@ -9,56 +11,118 @@
911
import requests
1012
from PIL import Image
1113

12-
from .profiles import Profile
14+
from . import Profile, __version__
15+
from .utils import debug, error
1316

1417
PREFERRED_ICONS = [
15-
"favicon.ico",
16-
"favicon.svg",
17-
"favicon.png",
18-
".ico",
18+
re.compile(p)
19+
for p in [
20+
r"favicon.*\.ico$",
21+
r"favicon.*\.svg$",
22+
r"favicon.*\.png$",
23+
r"\.ico$",
24+
r"icon\.png$",
25+
r"\.svg$",
26+
]
1927
]
2028

29+
# https://github.com/scottwernervt/favicon/blob/123e431f53b2c4903b540246a85db0b1633d4786/src/favicon/favicon.py#L40
2130
Icon = namedtuple("Icon", ["url", "width", "height", "format"])
2231

2332

2433
def choose_icon(icons: list[Icon]) -> Optional[Icon]:
25-
print(f"{icons=}")
26-
for key in PREFERRED_ICONS:
34+
for pattern in PREFERRED_ICONS:
2735
for icon in icons:
28-
if key in icon.url:
29-
print(f"chose {icon}")
36+
if pattern.search(icon.url):
37+
debug(f"chose {icon.url}")
3038
return icon
3139
return None
3240

3341

34-
def download_icon(profile: Profile, home_page: str) -> Optional[Path]:
42+
headers = {"user-agent": f"qbpm/{__version__}"}
43+
44+
45+
def download_icon(profile: Profile, home_page: str, overwrite: bool) -> Optional[Path]:
3546
if not urlparse(home_page).scheme:
3647
home_page = f"https://{home_page}"
37-
icons = favicon.get(home_page, timeout=10)
48+
try:
49+
icons = favicon.get(home_page, headers=headers, timeout=10)
50+
except Exception as e:
51+
debug(str(e))
52+
error(f"failed to fetch favicon from {home_page}")
53+
return None
54+
if not icons:
55+
error(f"no favicon found on {home_page}")
56+
return None
3857
icon = choose_icon(icons)
3958
if not icon:
40-
# TODO print
59+
print(f"no favicons found matching one of {PREFERRED_ICONS}")
4160
return None
4261

4362
tmp_dir = TemporaryDirectory()
4463
work_dir = Path(tmp_dir.name)
4564
work_icon = work_dir / f"favicon.{icon.format}"
46-
resp = requests.get(icon.url, timeout=10)
65+
icon_body = requests.get(icon.url, headers=headers, timeout=10)
4766
with work_icon.open("wb") as icon_file:
48-
for chunk in resp.iter_content(1024):
67+
for chunk in icon_body.iter_content(1024):
4968
icon_file.write(chunk)
5069

51-
icon_path = profile.root / f"icon.{icon.format}"
52-
if icon.format == "ico":
53-
icon_path = icon_path.with_suffix(".png")
54-
image = Image.open(work_icon)
55-
image.save(icon_path)
70+
return install_icon_file(profile, work_icon, overwrite, icon.url)
71+
72+
73+
def icon_for_profile(profile: Profile) -> Optional[str]:
74+
icon_file = next(find_icon_files(profile), None)
75+
if icon_file and icon_file.suffix == ".name":
76+
return icon_file.read_text()
77+
return str(icon_file) if icon_file else None
78+
79+
80+
def install_icon_file(
81+
profile: Profile, src: Path, overwrite: bool, origin: Optional[str] = None
82+
) -> Optional[Path]:
83+
if not check_or_replace_existing_icon(profile, overwrite):
84+
return None
85+
icon_format = src.suffix
86+
dest = profile.root / f"icon{icon_format}"
87+
if icon_format == ".ico":
88+
dest = dest.with_suffix(".png")
89+
try:
90+
image = Image.open(src)
91+
image.save(dest, icon_format="png")
92+
except Exception as e:
93+
debug(str(e))
94+
error(f"failed to convert {origin or src} to png")
95+
return None
5696
else:
57-
shutil.move(work_icon, icon_path)
97+
shutil.copy(src, dest)
98+
return dest.absolute()
99+
58100

59-
return icon_path.absolute()
101+
def install_icon_by_name(profile: Profile, icon_name: str, overwrite: bool) -> bool:
102+
if not check_or_replace_existing_icon(profile, overwrite):
103+
return False
104+
file = profile.root / "icon.name"
105+
file.write_text(icon_name)
106+
return True
107+
108+
109+
def check_or_replace_existing_icon(profile: Profile, overwrite: bool) -> bool:
110+
existing_icons = find_icon_files(profile)
111+
existing_icon = next(existing_icons, None)
112+
if not existing_icon:
113+
return True
114+
elif overwrite:
115+
existing_icon.unlink()
116+
for icon in existing_icons:
117+
icon.unlink()
118+
return True
119+
else:
120+
error(f"icon already exists in {profile.root}, pass --overwrite to replace it")
121+
return False
60122

61123

62-
def find_icon_file(profile: Profile) -> Optional[Path]:
63-
icon_path = next(filter(lambda p: p.is_file(), profile.root.glob("icon.*")), None)
64-
return icon_path.absolute() if icon_path else icon_path
124+
def find_icon_files(profile: Profile) -> Iterator[Path]:
125+
for ext in ["png", "svg", "name"]:
126+
icon = profile.root / f"icon.{ext}"
127+
if icon.is_file():
128+
yield icon.absolute()

qbpm/main.py

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -113,14 +113,20 @@ def launch(profile_dir: Path, profile_name: str, **kwargs: Any) -> None:
113113
@click.option(
114114
"-f", "--foreground", is_flag=True, help="Run qutebrowser in the foreground."
115115
)
116+
@click.option(
117+
"-i",
118+
"--icon",
119+
"force_icon",
120+
is_flag=True,
121+
help="Attach icons to menu items using rofi's extended dmenu spec even if your menu is not known to support it. Only works if at least one profile has an icon installed.",
122+
)
116123
@click.pass_obj
117124
def choose(profile_dir: Path, **kwargs: Any) -> None:
118125
"""Choose a profile to launch.
119126
Support is built in for many X and Wayland launchers, as well as applescript dialogs.
120127
All QB_ARGS are passed on to qutebrowser."
121128
"""
122-
# TODO force-icon
123-
exit_with(operations.choose(profile_dir=profile_dir, force_icon=False, **kwargs))
129+
exit_with(operations.choose(profile_dir=profile_dir, **kwargs))
124130

125131

126132
@main.command()
@@ -157,26 +163,21 @@ def desktop(
157163

158164
@main.command()
159165
@click.argument("profile_name")
160-
@click.argument("icon")
166+
@click.argument("icon", metavar="ICON_LOCATION")
167+
@click.option(
168+
"-n",
169+
"--by-name",
170+
is_flag=True,
171+
help="interpret ICON_LOCATION as the name of an icon in an installed icon theme instead of a file or url.",
172+
)
173+
@click.option("--overwrite", is_flag=True, help="Replace the current icon.")
161174
@click.pass_obj
162-
def icon(profile_dir: Path, profile_name: str, icon: str) -> None:
163-
# TODO support --all?
164-
"""Install an icon for the profile."""
175+
def icon(profile_dir: Path, profile_name: str, **kwargs: Any) -> None:
176+
"""Install an icon for the profile. ICON_LOCATION may be a url to download a favicon from,
177+
a path to an image file, or, with --by-name, the name of an xdg icon installed on your system.
178+
"""
165179
profile = Profile(profile_name, profile_dir)
166-
if not profile.exists():
167-
error(f"profile {profile.name} not found at {profile.root}")
168-
sys.exit(1)
169-
icon_file: Optional[Path]
170-
if Path(icon).is_file():
171-
# TODO move to profile dir
172-
# TODO overwrite protection
173-
icon_file = Path(icon)
174-
else:
175-
icon_file = profiles.download_icon(profile, icon)
176-
# TODO check if desktop file exists and should be updated
177-
desktop_file = False
178-
if desktop_file:
179-
profiles.create_desktop_file(profile, icon_file)
180+
exit_with(operations.icon(profile, **kwargs))
180181

181182

182183
def then_launch(

qbpm/operations.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77

88
from xdg import BaseDirectory
99

10-
from . import Profile, profiles
11-
from .icons import find_icon_file
10+
from . import Profile, config, icons, profiles
1211
from .utils import AUTO_MENUS, error, installed_menus
1312

1413

@@ -59,12 +58,29 @@ def launch(
5958
def desktop(profile: Profile) -> bool:
6059
exists = profile.exists()
6160
if exists:
62-
profiles.create_desktop_file(profile)
61+
profiles.create_desktop_file(profile, icons.icon_for_profile(profile))
6362
else:
6463
error(f"profile {profile.name} not found at {profile.root}")
6564
return exists
6665

6766

67+
def icon(profile: Profile, icon: str, by_name: bool, overwrite: bool) -> bool:
68+
if not profile.exists():
69+
error(f"profile {profile.name} not found at {profile.root}")
70+
return False
71+
if by_name:
72+
icon_id = icon if icons.install_icon_by_name(profile, icon, overwrite) else None
73+
else:
74+
if Path(icon).is_file():
75+
icon_file = icons.install_icon_file(profile, Path(icon), overwrite)
76+
else:
77+
icon_file = icons.download_icon(profile, icon, overwrite)
78+
icon_id = str(icon_file) if icon_file else None
79+
if icon_id:
80+
profiles.add_to_desktop_file(profile, "Icon", icon_id)
81+
return icon_id is not None
82+
83+
6884
def choose(
6985
profile_dir: Path,
7086
menu: str,
@@ -158,10 +174,10 @@ def menu_command(
158174

159175

160176
def build_menu_items(profiles: list[Profile], icon: bool) -> str:
161-
if icon and any(icons := [find_icon_file(p) for p in profiles]):
177+
if icon and any(profile_icons := [icons.icon_for_profile(p) for p in profiles]):
162178
menu_items = [
163-
f"{p.name}\0icon\x1f{icon or 'qutebrowser'}"
164-
for (p, icon) in zip(profiles, icons)
179+
f"{p.name}\0icon\x1f{icon or config.default_icon}"
180+
for (p, icon) in zip(profiles, profile_icons)
165181
]
166182
else:
167183
menu_items = [p.name for p in profiles]

qbpm/profiles.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
from xdg import BaseDirectory
66
from xdg.DesktopEntry import DesktopEntry
77

8-
from . import Profile
9-
from .icons import download_icon
8+
from . import Profile, config, icons
109
from .utils import error, user_config_dir
1110

1211

@@ -44,18 +43,26 @@ def create_config(
4443
application_dir = Path(BaseDirectory.xdg_data_home) / "applications" / "qbpm"
4544

4645

47-
def create_desktop_file(profile: Profile, icon: Path | str | None = None) -> None:
46+
def create_desktop_file(profile: Profile, icon: Optional[str] = None) -> None:
4847
desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop"))
49-
desktop.set("Name", f"{profile.name} (qutebrowser profile)")
50-
# TODO allow passing in an icon value
51-
desktop.set("Icon", icon or "qutebrowser")
48+
desktop.set("Name", f"{profile.name}{config.application_name_suffix}")
49+
desktop.set("Icon", icon or config.default_icon)
5250
desktop.set("Exec", " ".join(profile.cmdline()) + " %u")
5351
desktop.set("Categories", ["Network"])
5452
desktop.set("Terminal", False)
5553
desktop.set("StartupNotify", True)
5654
desktop.write()
5755

5856

57+
def add_to_desktop_file(profile: Profile, key: str, value: str) -> None:
58+
desktop_file = application_dir / f"{profile.name}.desktop"
59+
if not desktop_file.exists():
60+
return
61+
desktop = DesktopEntry(str(application_dir / f"{profile.name}.desktop"))
62+
desktop.set(key, value)
63+
desktop.write()
64+
65+
5966
def ensure_profile_exists(profile: Profile, create: bool = True) -> bool:
6067
if profile.root.exists() and not profile.root.is_dir():
6168
error(f"{profile.root} is not a directory")
@@ -78,8 +85,8 @@ def new_profile(
7885
create_config(profile, home_page, overwrite)
7986
if home_page:
8087
# TODO catch errors?
81-
icon = download_icon(profile, home_page)
88+
icon = icons.download_icon(profile, home_page, overwrite)
8289
if desktop_file:
83-
create_desktop_file(profile, icon)
90+
create_desktop_file(profile, str(icon) if icon else None)
8491
return True
8592
return False

qbpm/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
AUTO_MENUS = WAYLAND_MENUS + X11_MENUS
1313
SUPPORTED_MENUS = [*AUTO_MENUS, "fzf", "applescript"]
1414

15+
global debugging
16+
debugging: bool = True
17+
18+
19+
# TODO replace with actual logger
20+
def debug(msg: str) -> None:
21+
if debugging:
22+
print(f"debug: {msg}", file=stderr)
23+
1524

1625
def error(msg: str) -> None:
1726
print(f"error: {msg}", file=stderr)

0 commit comments

Comments
 (0)