1236 lines
36 KiB
Python
Executable File
1236 lines
36 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["pillow>=10"]
|
|
# ///
|
|
"""Render fastfetch output to an image (png, jpg, webp, gif). Colors are kept."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import getpass
|
|
import os
|
|
import pty
|
|
import re
|
|
import select
|
|
import shlex
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import termios
|
|
import time
|
|
import tty
|
|
from dataclasses import dataclass, field, replace
|
|
from pathlib import Path
|
|
from typing import cast, final
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
Color = int | tuple[int, int, int]
|
|
RGB = tuple[int, int, int]
|
|
|
|
CACHE_DIR = Path.home() / ".cache" / "fetch-snap"
|
|
ALIAS_ENV = "_FSC_ALIAS"
|
|
DEFAULT_BG: RGB = (18, 18, 18)
|
|
DEFAULT_FG: RGB = (230, 230, 230)
|
|
|
|
CAPTURE_COLS = 160
|
|
CAPTURE_QUIET = 1.5
|
|
CAPTURE_DEADLINE = 15.0
|
|
PROMPT_QUIET = 0.3
|
|
PROMPT_DEADLINE = 3.0
|
|
OSC_PROBE_TIMEOUT = 0.25
|
|
SCAN_DEPTH = 10
|
|
|
|
# ---------------------------------------------------------------- palette
|
|
|
|
STANDARD: list[RGB] = [
|
|
(0, 0, 0),
|
|
(128, 0, 0),
|
|
(0, 128, 0),
|
|
(128, 128, 0),
|
|
(0, 0, 128),
|
|
(128, 0, 128),
|
|
(0, 128, 128),
|
|
(192, 192, 192),
|
|
(128, 128, 128),
|
|
(255, 0, 0),
|
|
(0, 255, 0),
|
|
(255, 255, 0),
|
|
(0, 0, 255),
|
|
(255, 0, 255),
|
|
(0, 255, 255),
|
|
(255, 255, 255),
|
|
]
|
|
|
|
|
|
def xterm256(n: int) -> RGB:
|
|
if n < 16:
|
|
return STANDARD[n]
|
|
if n <= 231:
|
|
n -= 16
|
|
steps = (0, 95, 135, 175, 215, 255)
|
|
return steps[n // 36], steps[(n // 6) % 6], steps[n % 6]
|
|
v = 8 + (n - 232) * 10
|
|
return v, v, v
|
|
|
|
|
|
# ---------------------------------------------------------------- ansi model
|
|
|
|
|
|
@dataclass
|
|
class Attr:
|
|
fg: Color | None = None
|
|
bg: Color | None = None
|
|
bold: bool = False
|
|
inverse: bool = False
|
|
|
|
|
|
@dataclass
|
|
class Seg:
|
|
text: str
|
|
attr: Attr
|
|
|
|
|
|
@dataclass
|
|
class Theme:
|
|
fg: Color | None = None
|
|
bg: Color | None = None
|
|
colors: list[Color | None] = field(default_factory=lambda: [None] * 16)
|
|
extended: dict[int, Color] = field(default_factory=dict)
|
|
|
|
@property
|
|
def background(self) -> RGB:
|
|
return self.bg if isinstance(self.bg, tuple) else DEFAULT_BG
|
|
|
|
@property
|
|
def foreground(self) -> RGB:
|
|
return self.fg if isinstance(self.fg, tuple) else DEFAULT_FG
|
|
|
|
|
|
# ANSI escape grammar: CSI = ESC [ params/intermediates final
|
|
ANSI_TOKEN_RE = re.compile(
|
|
r"\x1b\[[\x20-\x3f]*[\x40-\x7e]|\x1bP[^\x1b]*(?:\x1b\\)?|\x1b\][^\x1b\x07]*(?:\x07|\x1b\\)|[\s\S]"
|
|
)
|
|
|
|
|
|
def apply_sgr(a: Attr, body: str) -> None:
|
|
if not body:
|
|
a.fg = a.bg = None
|
|
a.bold = a.inverse = False
|
|
return
|
|
parts = body.split(";")
|
|
i = 0
|
|
while i < len(parts):
|
|
v = int(parts[i]) if parts[i] else None
|
|
if v is None:
|
|
i += 1
|
|
continue
|
|
if v == 0:
|
|
a.fg = a.bg = None
|
|
a.bold = a.inverse = False
|
|
elif v == 1:
|
|
a.bold = True
|
|
elif v == 22:
|
|
a.bold = False
|
|
elif v == 7:
|
|
a.inverse = True
|
|
elif v == 27:
|
|
a.inverse = False
|
|
elif 30 <= v <= 37:
|
|
a.fg = v - 30
|
|
elif 90 <= v <= 97:
|
|
a.fg = v - 90 + 8
|
|
elif 40 <= v <= 47:
|
|
a.bg = v - 40
|
|
elif 100 <= v <= 107:
|
|
a.bg = v - 100 + 8
|
|
elif v == 39:
|
|
a.fg = None
|
|
elif v == 49:
|
|
a.bg = None
|
|
elif v in (38, 48) and i + 2 < len(parts) and parts[i + 1] == "5":
|
|
if v == 38:
|
|
a.fg = int(parts[i + 2])
|
|
else:
|
|
a.bg = int(parts[i + 2])
|
|
i += 3
|
|
continue
|
|
elif v in (38, 48) and i + 4 < len(parts) and parts[i + 1] == "2":
|
|
rgb = (int(parts[i + 2]), int(parts[i + 3]), int(parts[i + 4]))
|
|
if v == 38:
|
|
a.fg = rgb
|
|
else:
|
|
a.bg = rgb
|
|
i += 5
|
|
continue
|
|
i += 1
|
|
|
|
|
|
def parse_ansi(text: str) -> list[list[Seg]]:
|
|
lines: list[list[Seg]] = []
|
|
curline: list[Seg] = []
|
|
plain = ""
|
|
attr = Attr()
|
|
|
|
def emit() -> None:
|
|
nonlocal plain
|
|
if plain:
|
|
curline.append(Seg(plain, replace(attr)))
|
|
plain = ""
|
|
|
|
for tok in ANSI_TOKEN_RE.findall(text):
|
|
if tok.startswith("\x1b["):
|
|
if tok.endswith("m"):
|
|
emit()
|
|
apply_sgr(attr, tok[2:-1])
|
|
continue
|
|
if tok.startswith(("\x1bP", "\x1b]")):
|
|
continue
|
|
if tok == "\r":
|
|
continue
|
|
if tok == "\n":
|
|
emit()
|
|
lines.append(curline)
|
|
curline = []
|
|
continue
|
|
plain += tok
|
|
emit()
|
|
if curline:
|
|
lines.append(curline)
|
|
while lines and not lines[-1]:
|
|
lines.pop()
|
|
return lines
|
|
|
|
|
|
# ---------------------------------------------------------------- themes
|
|
|
|
|
|
def parse_color(s: str) -> Color | None:
|
|
s = s.strip()
|
|
if s.startswith("#") and len(s) == 7:
|
|
try:
|
|
return (int(s[1:3], 16), int(s[3:5], 16), int(s[5:7], 16))
|
|
except ValueError:
|
|
return None
|
|
if s.startswith("#") and len(s) == 4:
|
|
try:
|
|
return (int(s[1] * 2, 16), int(s[2] * 2, 16), int(s[3] * 2, 16))
|
|
except ValueError:
|
|
return None
|
|
if s.startswith("rgb:"):
|
|
parts = s[4:].split("/")
|
|
if len(parts) == 3 and all(parts):
|
|
try:
|
|
return (
|
|
int(parts[0][:2], 16),
|
|
int(parts[1][:2], 16),
|
|
int(parts[2][:2], 16),
|
|
)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def kitty_config_dir() -> Path:
|
|
k = os.environ.get("KITTY_CONFIG_DIRECTORY")
|
|
return Path(k).expanduser() if k else Path.home() / ".config" / "kitty"
|
|
|
|
|
|
def load_kitty_conf(theme: Theme, path: Path) -> None:
|
|
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
key, _, val = line.partition(" ")
|
|
if not val:
|
|
continue
|
|
key, val = key.strip(), val.strip()
|
|
if key == "background":
|
|
c = parse_color(val)
|
|
if c is not None:
|
|
theme.bg = c
|
|
elif key == "foreground":
|
|
c = parse_color(val)
|
|
if c is not None:
|
|
theme.fg = c
|
|
elif key.startswith("color"):
|
|
try:
|
|
idx = int(key[5:])
|
|
except ValueError:
|
|
continue
|
|
c = parse_color(val)
|
|
if c is None:
|
|
continue
|
|
if idx < 16:
|
|
theme.colors[idx] = c
|
|
else:
|
|
theme.extended[idx] = c
|
|
|
|
|
|
def find_theme_path(name: str) -> Path:
|
|
p = Path(name)
|
|
if p.is_file():
|
|
return p
|
|
dirs = [
|
|
kitty_config_dir() / "themes",
|
|
kitty_config_dir(),
|
|
Path.home() / ".local/share/kitty/themes",
|
|
Path("/usr/share/kitty/themes"),
|
|
]
|
|
for d in dirs:
|
|
for cand in (p, Path(name + ".conf")):
|
|
f = d / cand
|
|
if f.is_file():
|
|
return f
|
|
sys.exit(f"kitty theme not found: {name}")
|
|
|
|
|
|
def probe_terminal_colors() -> tuple[RGB | None, RGB | None]:
|
|
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
return None, None
|
|
fd = sys.stdin.fileno()
|
|
old = termios.tcgetattr(fd)
|
|
tty.setcbreak(fd)
|
|
buf = b""
|
|
try:
|
|
sys.stdout.write("\x1b]10;?\x07\x1b]11;?\x07")
|
|
sys.stdout.flush()
|
|
end = time.monotonic() + OSC_PROBE_TIMEOUT
|
|
while time.monotonic() < end:
|
|
r, _, _ = select.select([fd], [], [], max(0.0, end - time.monotonic()))
|
|
if not r:
|
|
break
|
|
chunk = os.read(fd, 1024)
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
finally:
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
fg = bg = None
|
|
m = re.search(rb"\x1b]10;rgb:([0-9a-f]+)/([0-9a-f]+)/([0-9a-f]+)", buf)
|
|
if m:
|
|
fg = (int(m.group(1)[:2], 16), int(m.group(2)[:2], 16), int(m.group(3)[:2], 16))
|
|
m = re.search(rb"\x1b]11;rgb:([0-9a-f]+)/([0-9a-f]+)/([0-9a-f]+)", buf)
|
|
if m:
|
|
bg = (int(m.group(1)[:2], 16), int(m.group(2)[:2], 16), int(m.group(3)[:2], 16))
|
|
return fg, bg
|
|
|
|
|
|
def build_theme(name: str | None, bg: str | None) -> Theme:
|
|
theme = Theme()
|
|
if name:
|
|
load_kitty_conf(theme, find_theme_path(name))
|
|
else:
|
|
conf = kitty_config_dir() / "kitty.conf"
|
|
if conf.is_file():
|
|
load_kitty_conf(theme, conf)
|
|
fg, bgc = probe_terminal_colors()
|
|
if fg is not None:
|
|
theme.fg = fg
|
|
if bgc is not None:
|
|
theme.bg = bgc
|
|
if bg:
|
|
c = parse_color(bg)
|
|
if c is None:
|
|
sys.exit(f"invalid background color: {bg}")
|
|
theme.bg = c
|
|
return theme
|
|
|
|
|
|
# ---------------------------------------------------------------- terminal
|
|
|
|
# Multiplexers fork new sessions exactly like a terminal would, so the
|
|
# session boundary alone cannot tell them apart from a real terminal.
|
|
PTY_MUX = {"tmux", "screen", "zellij"}
|
|
|
|
NAME_RE = re.compile(r"[A-Za-z0-9._+-]{1,40}")
|
|
|
|
|
|
def _proc_info(pid: int) -> tuple[str, int, int] | None:
|
|
try:
|
|
with open(f"/proc/{pid}/stat") as f:
|
|
parts = f.read().split()
|
|
comm = parts[1].strip("()")
|
|
ppid = int(parts[3])
|
|
with open(f"/proc/{pid}/sessionid") as f:
|
|
session = int(f.read())
|
|
return comm, ppid, session
|
|
except (OSError, ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def detect_terminal() -> str:
|
|
if os.environ.get("KITTY_WINDOW_ID"):
|
|
return "kitty"
|
|
prog = os.environ.get("TERM_PROGRAM")
|
|
if prog:
|
|
return prog
|
|
sid = os.getsid(0)
|
|
pid = os.getppid()
|
|
for _ in range(SCAN_DEPTH):
|
|
if pid <= 1:
|
|
break
|
|
info = _proc_info(pid)
|
|
if info is None:
|
|
break
|
|
comm, ppid, psid = info
|
|
if psid != sid:
|
|
if comm in PTY_MUX:
|
|
pid = ppid
|
|
continue
|
|
return comm
|
|
pid = ppid
|
|
return "terminal"
|
|
|
|
|
|
TERM_SHELL_SCRIPT = '#!/bin/sh\n"${SHELL:-/bin/bash}" "$1" "$2"\n:\n'
|
|
|
|
|
|
def term_shell(name: str) -> str | None:
|
|
if not NAME_RE.fullmatch(name):
|
|
return None
|
|
d = CACHE_DIR / "w"
|
|
try:
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
link = d / name
|
|
link.unlink(missing_ok=True)
|
|
link.write_text(TERM_SHELL_SCRIPT)
|
|
link.chmod(0o755)
|
|
return str(link)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def term_alias(name: str, site: str | None) -> str | None:
|
|
if not name or not NAME_RE.fullmatch(name):
|
|
return None
|
|
try:
|
|
exe = os.readlink("/proc/self/exe")
|
|
root = Path(exe).resolve().parent.parent
|
|
tree = CACHE_DIR / "uvpy"
|
|
if not tree.is_dir():
|
|
shutil.copytree(root, tree, symlinks=True, ignore_dangling_symlinks=True)
|
|
target = tree / "bin" / name
|
|
if not target.is_file():
|
|
if target.exists():
|
|
target.unlink()
|
|
shutil.copy2(exe, target)
|
|
target.chmod(0o755)
|
|
cfg = tree / "pyvenv.cfg"
|
|
if not cfg.is_file():
|
|
cfg.write_text(f"home = {Path(exe).resolve().parent}\n")
|
|
if site:
|
|
sp = (
|
|
tree
|
|
/ "lib"
|
|
/ f"python{sys.version_info[0]}.{sys.version_info[1]}"
|
|
/ "site-packages"
|
|
)
|
|
sp.parent.mkdir(parents=True, exist_ok=True)
|
|
if sp.is_symlink() and os.readlink(sp) == site:
|
|
pass
|
|
else:
|
|
if sp.exists():
|
|
shutil.rmtree(sp)
|
|
sp.symlink_to(site, target_is_directory=True)
|
|
return str(target)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def terminal_version(name: str) -> tuple[int, int, int] | None:
|
|
bin_ = shutil.which(name)
|
|
if not bin_:
|
|
return None
|
|
try:
|
|
run = subprocess.run(
|
|
[bin_, "--version"], check=False, capture_output=True, text=True, timeout=5
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
m = re.search(r"(\d+)\.(\d+)\.(\d+)", run.stdout or "")
|
|
if not m:
|
|
return None
|
|
return (
|
|
min(99, int(m.group(1))),
|
|
min(99, int(m.group(2))),
|
|
min(99, int(m.group(3))),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------- pty capture
|
|
|
|
|
|
def kitty_terminal_font() -> tuple[str, str]:
|
|
family = size = None
|
|
conf = kitty_config_dir() / "kitty.conf"
|
|
if conf.is_file():
|
|
for line in conf.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
line = line.strip()
|
|
if family is None and line.startswith("font_family "):
|
|
family = line.split(None, 1)[1].strip()
|
|
elif size is None and line.startswith("font_size "):
|
|
size = line.split(None, 1)[1].strip()
|
|
return family or "monospace", size or "11"
|
|
|
|
|
|
@final
|
|
class QueryResponder:
|
|
"""Answers terminal protocol queries fastfetch emits into the pty."""
|
|
|
|
def __init__(
|
|
self,
|
|
fd: int,
|
|
font: tuple[str, str],
|
|
da_version: tuple[int, int, int] | None,
|
|
) -> None:
|
|
self._fd = fd
|
|
self._font = font
|
|
self._da_version = da_version
|
|
self._pos = 0
|
|
|
|
def feed(self, buf: bytes) -> None:
|
|
family, size = self._font
|
|
searched = self._pos
|
|
while True:
|
|
di = buf.find(b"\x1bP+q", searched)
|
|
ci = buf.find(b"\x1b[>c", searched)
|
|
if di < 0 and ci < 0:
|
|
self._pos = max(self._pos, len(buf) - 8)
|
|
return
|
|
if ci < 0 or (di >= 0 and di < ci):
|
|
end = buf.find(b"\x1b\\", di + 4)
|
|
if end < 0:
|
|
self._pos = max(self._pos, di)
|
|
return
|
|
resp = b""
|
|
for qid in buf[di + 4 : end].decode("ascii", "replace").split(";"):
|
|
try:
|
|
dec = bytes.fromhex(qid).decode("utf-8", "replace")
|
|
except ValueError:
|
|
continue
|
|
if dec == "kitty-query-font_family":
|
|
resp += (
|
|
b"\x1bP1+r"
|
|
+ (dec + "=" + family).encode().hex().encode()
|
|
+ b"\x1b\\"
|
|
)
|
|
elif dec == "kitty-query-font_size":
|
|
resp += (
|
|
b"\x1bP1+r"
|
|
+ (dec + "=" + size).encode().hex().encode()
|
|
+ b"\x1b\\"
|
|
)
|
|
if resp:
|
|
try:
|
|
os.write(self._fd, resp)
|
|
except OSError:
|
|
pass
|
|
searched = end + 2
|
|
else:
|
|
if self._da_version:
|
|
ver = "".join(f"{v:02d}" for v in self._da_version)
|
|
try:
|
|
os.write(self._fd, b"\x1b[>1;" + ver.encode() + b";0c")
|
|
except OSError:
|
|
pass
|
|
searched = ci + 4
|
|
|
|
|
|
def read_pty(
|
|
fd: int, responder: QueryResponder | None, quiet: float, deadline: float
|
|
) -> bytes:
|
|
buf = b""
|
|
tail = time.monotonic()
|
|
while time.monotonic() < deadline:
|
|
r, _, _ = select.select([fd], [], [], 0.05)
|
|
if not r:
|
|
if buf and time.monotonic() - tail > quiet:
|
|
break
|
|
continue
|
|
try:
|
|
chunk = os.read(fd, 65536)
|
|
except OSError:
|
|
break
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
tail = time.monotonic()
|
|
if responder:
|
|
responder.feed(buf)
|
|
return buf
|
|
|
|
|
|
def reap_child(pid: int, fd: int) -> None:
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
os.waitpid(pid, 0)
|
|
except OSError:
|
|
pass
|
|
try:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def capture_prompt() -> str | None:
|
|
shell = os.environ.get("SHELL") or "bash"
|
|
if not shell or not Path(shell).is_file():
|
|
return None
|
|
try:
|
|
pid, fd = pty.fork()
|
|
except OSError:
|
|
return None
|
|
if pid == 0:
|
|
try:
|
|
os.environ["TERM"] = os.environ.get("TERM") or "xterm-256color"
|
|
os.execv(shell, [shell, "-i"])
|
|
except OSError:
|
|
os._exit(127)
|
|
buf = read_pty(
|
|
fd, None, quiet=PROMPT_QUIET, deadline=time.monotonic() + PROMPT_DEADLINE
|
|
)
|
|
reap_child(pid, fd)
|
|
if not buf:
|
|
return None
|
|
text = buf.replace(b"\r", b"").decode("utf-8", "replace")
|
|
text = re.sub(r"\x1b\][^\x1b\x07]*(\x07|\x1b\\)", "", text)
|
|
lines = text.split("\n")
|
|
clean = [re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", ln) for ln in lines]
|
|
for i in range(len(clean) - 1, -1, -1):
|
|
if clean[i].strip():
|
|
return lines[i]
|
|
return None
|
|
|
|
|
|
def pty_capture(terminal: str, argv: list[str]) -> str | None:
|
|
if not os.environ.get(ALIAS_ENV):
|
|
try:
|
|
own = os.path.basename(os.readlink("/proc/self/exe"))
|
|
except OSError:
|
|
own = terminal
|
|
if own != terminal:
|
|
sps = [p for p in sys.path if p.endswith("site-packages")]
|
|
alias = term_alias(terminal, sps[0] if sps else None)
|
|
if alias:
|
|
os.environ[ALIAS_ENV] = "1"
|
|
os.environ["PYTHONHOME"] = str(Path(alias).resolve().parent.parent)
|
|
try:
|
|
os.execv(alias, [alias] + sys.argv)
|
|
except OSError:
|
|
pass
|
|
if terminal != "kitty":
|
|
os.environ.pop("KITTY_WINDOW_ID", None)
|
|
try:
|
|
pid, fd = pty.fork()
|
|
except OSError:
|
|
return None
|
|
if pid == 0:
|
|
try:
|
|
os.environ.setdefault("KITTY_INSTALLATION_DIR", "kitty")
|
|
if terminal == "kitty":
|
|
os.environ.setdefault("KITTY_WINDOW_ID", "1")
|
|
os.execv(argv[0], argv)
|
|
except OSError:
|
|
os._exit(127)
|
|
try:
|
|
attrs = termios.tcgetattr(fd)
|
|
attrs[3] = attrs[3] & ~termios.ECHO
|
|
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
except (termios.error, OSError):
|
|
pass
|
|
responder = QueryResponder(
|
|
fd,
|
|
font=kitty_terminal_font(),
|
|
da_version=terminal_version(terminal) if terminal == "foot" else None,
|
|
)
|
|
buf = read_pty(
|
|
fd, responder, quiet=CAPTURE_QUIET, deadline=time.monotonic() + CAPTURE_DEADLINE
|
|
)
|
|
reap_child(pid, fd)
|
|
return buf.replace(b"\r", b"").decode("utf-8", "replace")
|
|
|
|
|
|
def fetch_output(fetch: str, icon: str | None, raw: bool) -> str:
|
|
cmd = [os.path.expanduser(p) for p in shlex.split(fetch)] or ["fastfetch"]
|
|
if icon:
|
|
cmd += ["--logo", icon]
|
|
joined = shlex.join(cmd)
|
|
shell = os.environ.get("SHELL") or "bash"
|
|
inner = f"stty cols {CAPTURE_COLS}; " + joined + " ; :"
|
|
terminal = detect_terminal()
|
|
wrapper = term_shell(terminal)
|
|
if wrapper:
|
|
argv = [wrapper] + (["-ic", inner] if not raw else ["-c", inner])
|
|
out = pty_capture(terminal, argv)
|
|
if out:
|
|
return out
|
|
argv = [shell] + (["-ic", inner] if not raw else ["-c", inner])
|
|
out = pty_capture(terminal, argv)
|
|
if out:
|
|
return out
|
|
sc = shutil.which("script")
|
|
if sc:
|
|
run = subprocess.run(
|
|
[sc, "-qec", inner, "/dev/null"], check=False, capture_output=True
|
|
)
|
|
data = run.stdout.replace(b"\r", b"")
|
|
if data.strip():
|
|
return data.decode("utf-8", "replace")
|
|
run = subprocess.run(cmd, check=False, capture_output=True)
|
|
data = run.stdout.replace(b"\r", b"")
|
|
if not data.strip():
|
|
sys.exit(
|
|
"fastfetch produced no output: "
|
|
+ run.stderr.decode("utf-8", "replace").strip()
|
|
)
|
|
return data.decode("utf-8", "replace")
|
|
|
|
|
|
# ---------------------------------------------------------------- fonts
|
|
|
|
FONT_NAMES = ["DejaVu Sans Mono", "Liberation Mono", "Noto Sans Mono", "monospace"]
|
|
FONT_PATHS = [
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSansMono.ttf",
|
|
"/usr/share/fonts/liberation/LiberationMono-Regular.ttf",
|
|
"/usr/share/fonts/noto/NotoSansMono-Regular.ttf",
|
|
]
|
|
BOLD_FONT_PATHS = [
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf",
|
|
"/usr/share/fonts/liberation/LiberationMono-Bold.ttf",
|
|
"/usr/share/fonts/noto/NotoSansMono-Bold.ttf",
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class FontSet:
|
|
base: ImageFont.FreeTypeFont
|
|
bold: ImageFont.FreeTypeFont | None
|
|
base_path: str | None
|
|
bold_path: str | None
|
|
fallbacks: list[str]
|
|
|
|
|
|
def scan_font_dirs() -> list[str]:
|
|
found: list[str] = []
|
|
dirs = [
|
|
Path.home() / ".local/share/fonts",
|
|
Path.home() / ".fonts",
|
|
Path("/usr/share/fonts"),
|
|
Path("/usr/local/share/fonts"),
|
|
]
|
|
for d in dirs:
|
|
if not d.is_dir():
|
|
continue
|
|
try:
|
|
for f in sorted(d.rglob("*")):
|
|
if f.suffix.lower() in (".ttf", ".otf"):
|
|
found.append(str(f))
|
|
except OSError:
|
|
continue
|
|
return found
|
|
|
|
|
|
def fc_match(pattern: str) -> str | None:
|
|
try:
|
|
out = subprocess.run(
|
|
["fc-match", "-f", "%{file}", pattern],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
got = out.stdout.strip()
|
|
return got or None
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
|
|
|
|
def charset_ranges(path: str | None) -> list[tuple[int, int]] | None:
|
|
if not path:
|
|
return None
|
|
try:
|
|
out = subprocess.run(
|
|
["fc-query", "--format", "%{charset}", path],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return None
|
|
ranges = []
|
|
for part in out.stdout.strip().split():
|
|
if "-" in part:
|
|
a, b = part.split("-", 1)
|
|
ranges.append((int(a, 16), int(b, 16)))
|
|
else:
|
|
ranges.append((int(part, 16), int(part, 16)))
|
|
return ranges or None
|
|
|
|
|
|
def covers(ranges: list[tuple[int, int]] | None, ch: str) -> bool:
|
|
if not ranges:
|
|
return True
|
|
cp = ord(ch)
|
|
lo, hi = 0, len(ranges) - 1
|
|
while lo <= hi:
|
|
mid = (lo + hi) // 2
|
|
a, b = ranges[mid]
|
|
if cp < a:
|
|
hi = mid - 1
|
|
elif cp > b:
|
|
lo = mid + 1
|
|
else:
|
|
return True
|
|
return False
|
|
|
|
|
|
def try_font(
|
|
cands: list[str], size: float
|
|
) -> tuple[ImageFont.FreeTypeFont | None, str | None]:
|
|
for c in cands:
|
|
try:
|
|
return ImageFont.truetype(c, size), c
|
|
except OSError:
|
|
continue
|
|
return None, None
|
|
|
|
|
|
def font_path_of(name: str | None) -> str | None:
|
|
if name and Path(name).suffix.lower() in (".ttf", ".otf"):
|
|
return name
|
|
return fc_match(name) if name else None
|
|
|
|
|
|
def resolve_fonts(size: float, path: str | None) -> FontSet:
|
|
scanned = scan_font_dirs()
|
|
nerds = sorted(
|
|
f for f in scanned if "Nerd" in Path(f).name or "NerdFont" in Path(f).name
|
|
)
|
|
nerd_mono = [f for f in nerds if "Mono" in f]
|
|
nerd_rest = [f for f in nerds if "Mono" not in f]
|
|
others = [f for f in scanned if f not in nerds]
|
|
explicit = []
|
|
if path:
|
|
if Path(path).is_file():
|
|
explicit.append(path)
|
|
else:
|
|
f = fc_match(path)
|
|
if f:
|
|
explicit.append(f)
|
|
cands = explicit + nerd_mono + nerd_rest
|
|
for pat in ("monospace", "mono"):
|
|
m = fc_match(pat)
|
|
if m and m not in cands:
|
|
cands.append(m)
|
|
cands += FONT_PATHS + FONT_NAMES + others
|
|
base, base_name = try_font(cands, size)
|
|
if base is None:
|
|
sys.exit("cannot load a font, pass --font <path.ttf>")
|
|
base_path = font_path_of(base_name)
|
|
bold_cands = []
|
|
if base_path:
|
|
p = Path(base_path)
|
|
stem = p.stem.replace("-Regular", "").replace("Regular", "")
|
|
for cand in (f"{stem}-Bold", f"{stem}Bold", p.stem + "-Bold", p.stem + "Bold"):
|
|
bold_cands.append(str(p.with_name(cand + p.suffix)))
|
|
for pat in ("monospace:bold", "mono:bold"):
|
|
m = fc_match(pat)
|
|
if m and m not in bold_cands:
|
|
bold_cands.append(m)
|
|
bold_cands += BOLD_FONT_PATHS
|
|
bold, bold_name = try_font(bold_cands, size)
|
|
bold_path = font_path_of(bold_name) if bold is not None else None
|
|
fallbacks: list[str] = []
|
|
for fam in (
|
|
"Symbols Nerd Font",
|
|
"JetBrainsMono Nerd Font",
|
|
"FiraCode Nerd Font",
|
|
"Noto Sans Symbols 2",
|
|
"Noto Sans Symbols",
|
|
"Symbola",
|
|
"Unifont",
|
|
):
|
|
f = fc_match(fam)
|
|
if f and f not in fallbacks:
|
|
fallbacks.append(f)
|
|
fallbacks += nerds + others + FONT_PATHS
|
|
dedup: list[str] = []
|
|
for f in fallbacks:
|
|
if f not in dedup:
|
|
dedup.append(f)
|
|
if base_path:
|
|
dedup = [f for f in dedup if f != base_path]
|
|
return FontSet(base, bold, base_path, bold_path, dedup)
|
|
|
|
|
|
@final
|
|
class FontPicker:
|
|
"""Picks a glyph-matching font per character, with lazy caches."""
|
|
|
|
def __init__(self, fonts: FontSet, size: float) -> None:
|
|
self.base = fonts.base
|
|
self.bold = fonts.bold
|
|
self.size = size
|
|
self.fallbacks = fonts.fallbacks
|
|
self.primary_cs = charset_ranges(fonts.base_path)
|
|
self.bold_cs = charset_ranges(fonts.bold_path) or self.primary_cs
|
|
self._fonts: dict[str, ImageFont.FreeTypeFont] = {}
|
|
self._cs: dict[str, list[tuple[int, int]] | None] = {}
|
|
if fonts.base_path:
|
|
self._cs[fonts.base_path] = self.primary_cs
|
|
|
|
def covers(self, ch: str, is_bold: bool) -> bool:
|
|
return covers(self.bold_cs if is_bold else self.primary_cs, ch)
|
|
|
|
def pick(self, ch: str, is_bold: bool) -> ImageFont.FreeTypeFont:
|
|
if self.bold is not None and is_bold and covers(self.bold_cs, ch):
|
|
return self.bold
|
|
if covers(self.primary_cs, ch):
|
|
return self.base
|
|
fb = self._fallback(ch)
|
|
return fb or (self.bold if (self.bold is not None and is_bold) else self.base)
|
|
|
|
def _fallback(self, ch: str) -> ImageFont.FreeTypeFont | None:
|
|
base_path = next((p for p in self._cs if p), None)
|
|
m = fc_match(f":charset={ord(ch):X}")
|
|
cands = ([m] if m and m != base_path else []) + self.fallbacks
|
|
for cand in cands:
|
|
if cand not in self._cs:
|
|
self._cs[cand] = charset_ranges(cand)
|
|
if not covers(self._cs[cand], ch):
|
|
continue
|
|
f = self._fonts.get(cand)
|
|
if f is None:
|
|
f = try_font([cand], self.size)[0]
|
|
if f is None:
|
|
continue
|
|
self._fonts[cand] = f
|
|
return f
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- rendering
|
|
|
|
|
|
def resolve_color(c: Color | None, bold: bool, theme: Theme) -> RGB:
|
|
if c is None:
|
|
return theme.foreground
|
|
if isinstance(c, tuple):
|
|
return c
|
|
idx = c
|
|
if bold and idx < 8:
|
|
idx += 8
|
|
if idx < 16:
|
|
return cast(RGB, theme.colors[idx] or STANDARD[idx])
|
|
return cast(RGB, theme.extended.get(idx) or xterm256(idx))
|
|
|
|
|
|
def render(
|
|
lines: list[list[Seg]],
|
|
fonts: FontSet,
|
|
theme: Theme,
|
|
pad: int,
|
|
size: float,
|
|
) -> Image.Image:
|
|
base = fonts.base
|
|
metrics = base.getmetrics()
|
|
line_h = metrics[0] + metrics[1] + 2
|
|
widths = []
|
|
for line in lines:
|
|
w = 0.0
|
|
for seg in line:
|
|
f = fonts.bold if (seg.attr.bold and fonts.bold is not None) else base
|
|
w += f.getlength(seg.text)
|
|
widths.append(w)
|
|
picker = FontPicker(fonts, size)
|
|
img = Image.new(
|
|
"RGB",
|
|
(int(max(widths)) + 1 + 2 * pad, line_h * len(lines) + 2 * pad),
|
|
theme.background,
|
|
)
|
|
d = ImageDraw.Draw(img)
|
|
for li, line in enumerate(lines):
|
|
top = pad + li * line_h
|
|
baseline = pad + li * line_h + metrics[0] + 1
|
|
x = float(pad)
|
|
for seg in line:
|
|
a = seg.attr
|
|
is_bold = a.bold and fonts.bold is not None
|
|
f = fonts.bold if is_bold else base
|
|
if f is None:
|
|
f = base
|
|
fg = resolve_color(a.fg, a.bold, theme)
|
|
bgc = None if a.bg is None else resolve_color(a.bg, False, theme)
|
|
if a.inverse:
|
|
fg, bgc = bgc, fg
|
|
if fg is None:
|
|
fg = theme.background
|
|
need_fb = any(not picker.covers(ch, is_bold) for ch in seg.text)
|
|
if not need_fb:
|
|
w = f.getlength(seg.text)
|
|
if w <= 0:
|
|
continue
|
|
if bgc is not None:
|
|
d.rectangle([x, top, x + w, top + line_h], fill=bgc)
|
|
if a.bold and fonts.bold is None:
|
|
d.text(
|
|
(x, baseline),
|
|
seg.text,
|
|
font=base,
|
|
fill=fg,
|
|
anchor="ls",
|
|
stroke_width=1,
|
|
)
|
|
d.text((x, baseline), seg.text, font=f, fill=fg, anchor="ls")
|
|
x += w
|
|
else:
|
|
wl = []
|
|
for ch in seg.text:
|
|
ff = picker.pick(ch, is_bold)
|
|
wl.append((ff, ff.getlength(ch)))
|
|
total = sum(w for _, w in wl)
|
|
if bgc is not None:
|
|
d.rectangle([x, top, x + total, top + line_h], fill=bgc)
|
|
xc = x
|
|
for ch, (ff, w) in zip(seg.text, wl):
|
|
if a.bold and fonts.bold is None and ff is base:
|
|
d.text(
|
|
(xc, baseline),
|
|
ch,
|
|
font=base,
|
|
fill=fg,
|
|
anchor="ls",
|
|
stroke_width=1,
|
|
)
|
|
d.text((xc, baseline), ch, font=ff, fill=fg, anchor="ls")
|
|
xc += w
|
|
x += total
|
|
return img
|
|
|
|
|
|
def save_image(img: Image.Image, path: str, fmt: str) -> None:
|
|
if fmt == "gif":
|
|
img.convert("P", palette=Image.ADAPTIVE, colors=256).save(path, format="GIF") # pyright: ignore[reportAttributeAccessIssue]
|
|
elif fmt == "jpg":
|
|
img.save(path, format="JPEG", quality=92)
|
|
elif fmt == "webp":
|
|
img.save(path, format="WEBP", lossless=True)
|
|
else:
|
|
img.save(path, format="PNG")
|
|
|
|
|
|
# ---------------------------------------------------------------- prompt
|
|
|
|
|
|
@dataclass
|
|
class PromptParts:
|
|
user: str
|
|
host: str
|
|
dir: str
|
|
marker: str
|
|
style: str
|
|
|
|
|
|
def prompt_parts(shell: str | None) -> PromptParts:
|
|
try:
|
|
user = getpass.getuser()
|
|
except OSError:
|
|
user = "user"
|
|
try:
|
|
host = socket.gethostname().split(".")[0]
|
|
except OSError:
|
|
host = "localhost"
|
|
home = os.path.expanduser("~")
|
|
cwd = os.getcwd()
|
|
if cwd == home:
|
|
dir_ = "~"
|
|
elif cwd.startswith(home + os.sep):
|
|
dir_ = "~" + cwd[len(home) :]
|
|
else:
|
|
dir_ = cwd
|
|
style = (shell or os.environ.get("SHELL", "") or "bash").strip().lower()
|
|
style = style.rsplit("/", 1)[-1]
|
|
marker = "#" if user == "root" else "$"
|
|
if style.startswith("fish"):
|
|
return PromptParts(user, host, dir_, ">", "plain")
|
|
if style.startswith("zsh"):
|
|
return PromptParts(user, host, dir_, "%", "plain")
|
|
if style.startswith("nu"):
|
|
return PromptParts("", "", dir_, ">", "nu")
|
|
if style.startswith("pwsh") or "powershell" in style:
|
|
return PromptParts("", "", dir_, ">", "pwsh")
|
|
return PromptParts(user, host, dir_, marker, "bracket")
|
|
|
|
|
|
def make_prompt(shell: str | None) -> list[Seg]:
|
|
p = prompt_parts(shell)
|
|
|
|
def bright(i: int) -> Attr:
|
|
return Attr(fg=i + 8, bold=True)
|
|
|
|
if p.style == "nu":
|
|
return [Seg(p.dir, bright(4)), Seg("> ", Attr())]
|
|
if p.style == "pwsh":
|
|
return [Seg("PS ", bright(4)), Seg(p.dir, Attr()), Seg("> ", Attr())]
|
|
if p.style == "plain":
|
|
return [
|
|
Seg(f"{p.user}@{p.host}", bright(2)),
|
|
Seg(f" {p.dir}", bright(4)),
|
|
Seg(p.marker + " ", Attr()),
|
|
]
|
|
return [
|
|
Seg("[", Attr()),
|
|
Seg(f"{p.user}@{p.host}", bright(2)),
|
|
Seg(f" {p.dir}]", bright(4)),
|
|
Seg(p.marker + " ", Attr()),
|
|
]
|
|
|
|
|
|
def command_line(fetch: str, icon: str | None) -> list[Seg]:
|
|
cmd = [fetch]
|
|
if icon:
|
|
cmd += ["--logo", icon]
|
|
return [Seg(shlex.join(cmd), Attr())]
|
|
|
|
|
|
def prompt_lines(prompt_text: str | None) -> list[list[Seg]] | None:
|
|
if not prompt_text:
|
|
return None
|
|
lines = parse_ansi(prompt_text)
|
|
while lines and not lines[-1]:
|
|
lines.pop()
|
|
return lines or None
|
|
|
|
|
|
# ---------------------------------------------------------------- cli
|
|
|
|
|
|
class Args(argparse.Namespace):
|
|
output: str | None
|
|
format: str | None
|
|
icon: str | None
|
|
theme: str | None
|
|
prompt: str | None
|
|
shell: str | None
|
|
raw: bool
|
|
bg: str | None
|
|
scale: float
|
|
font_size: int
|
|
font: str | None
|
|
padding: int
|
|
fastfetch: str
|
|
|
|
|
|
def parse_args(argv: list[str] | None) -> Args:
|
|
p = argparse.ArgumentParser(
|
|
prog="fetch-snap",
|
|
description="Render fastfetch output to an image, keeping all colors.",
|
|
)
|
|
p.add_argument(
|
|
"-o",
|
|
"--output",
|
|
help="output file (default: fetch.<format>); extension picks the format",
|
|
)
|
|
p.add_argument(
|
|
"-f",
|
|
"--format",
|
|
choices=["png", "jpg", "webp", "gif"],
|
|
help="image format (default: png)",
|
|
)
|
|
p.add_argument(
|
|
"--icon",
|
|
help="fetch icon (logo) to use, passed to fastfetch --logo, e.g. ascii, nerd_font",
|
|
)
|
|
p.add_argument(
|
|
"--theme",
|
|
help="kitty color theme name (searched in kitty theme dirs) or a .conf file path",
|
|
)
|
|
p.add_argument(
|
|
"--prompt",
|
|
choices=["top", "bottom", "both"],
|
|
help="add a real shell prompt line above and/or below the output, captured from $SHELL",
|
|
)
|
|
p.add_argument(
|
|
"--shell",
|
|
help="fallback prompt style if the real prompt cannot be captured: bash, zsh, fish, nu, pwsh (default: $SHELL)",
|
|
)
|
|
p.add_argument(
|
|
"--raw",
|
|
action="store_true",
|
|
help="run the fastfetch command directly, do not expand shell aliases",
|
|
)
|
|
p.add_argument(
|
|
"--bg",
|
|
help="background color, e.g. #1e1e2e (default: dark gray, or the theme background)",
|
|
)
|
|
p.add_argument(
|
|
"--scale",
|
|
type=float,
|
|
default=2,
|
|
help="render scale, multiplies font size and padding (default: 2)",
|
|
)
|
|
p.add_argument(
|
|
"--font-size",
|
|
type=int,
|
|
default=16,
|
|
help="font size in px at scale 1 (default: 16)",
|
|
)
|
|
p.add_argument("--font", help="path to a ttf/otf font")
|
|
p.add_argument(
|
|
"--padding",
|
|
type=int,
|
|
default=14,
|
|
help="padding around the text in px (default: 14)",
|
|
)
|
|
p.add_argument(
|
|
"--fastfetch",
|
|
default="fastfetch",
|
|
help="command to run (default: fastfetch), run through an interactive shell unless --raw",
|
|
)
|
|
return cast(Args, p.parse_args(argv))
|
|
|
|
|
|
def resolve_output(output: str | None, fmt: str | None) -> tuple[str, str]:
|
|
if not fmt:
|
|
ext = Path(output).suffix[1:].lower() if output else ""
|
|
fmt = ext if ext in ("png", "jpg", "jpeg", "webp", "gif") else "png"
|
|
if fmt == "jpeg":
|
|
fmt = "jpg"
|
|
return output or f"fetch.{fmt}", fmt
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
args = parse_args(argv)
|
|
theme = build_theme(args.theme, args.bg)
|
|
text = fetch_output(args.fastfetch, args.icon, args.raw)
|
|
lines = parse_ansi(text)
|
|
prompt = prompt_lines(capture_prompt() if args.prompt else None)
|
|
if args.prompt in ("top", "both"):
|
|
top = list(prompt or [make_prompt(args.shell)])
|
|
top[-1] = top[-1] + command_line(args.fastfetch, args.icon)
|
|
lines[:0] = top
|
|
if args.prompt in ("bottom", "both"):
|
|
lines.extend(prompt or [make_prompt(args.shell)])
|
|
size = args.font_size * args.scale
|
|
pad = round(args.padding * args.scale)
|
|
fonts = resolve_fonts(size, args.font)
|
|
if fonts.base_path:
|
|
print(f"font: {fonts.base_path}", file=sys.stderr)
|
|
if fonts.bold_path and fonts.bold_path != fonts.base_path:
|
|
print(f"bold: {fonts.bold_path}", file=sys.stderr)
|
|
img = render(lines, fonts, theme, pad, size)
|
|
out, fmt = resolve_output(args.output, args.format)
|
|
save_image(img, out, fmt)
|
|
print(f"wrote {out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|