diff --git a/.gitignore b/.gitignore index b609f70..c61a783 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .wakatime-project .ruff_cache/ .flatpak-builder/ -.ignore/ +*.ignore* .direnv/ # Byte-compiled / optimized / DLL files diff --git a/README.md b/README.md index bbfe33a..c7e7e86 100644 --- a/README.md +++ b/README.md @@ -332,6 +332,8 @@ Also, ffmpeg can generate the the same image for 2 consecutive frames, which may Currently only the `symbols` format of chafa is supported, formats like kitty, iterm etc. are not supported. If you try to tell chafa to use iterm, kitty etc. it will just override your format with `symbols` mode. +Most fastfetch configs/presets should work out of the box. If you run into an issue with a particular preset, just open an issue and paste your config. + ## What's Next - [ ] Support different formats like iterm, kitty, sixel etc. diff --git a/pyproject.toml b/pyproject.toml index 12ebdb0..d9e6a7b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,56 +1,60 @@ -# TODO: publish on pypi - [build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" + requires = ["setuptools>=61.0"] + build-backend = "setuptools.build_meta" [project] -name = "anifetch-cli" -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Topic :: Terminals", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX", - "Operating System :: Unix", - "Operating System :: MacOS", -] -keywords = [ - "fetch", - "terminal", - "cli", - "animated", - "neofetch", - "fastfetch", - "anifetch", -] -version = "1.0.4" -description = "Animated terminal fetch with video and audio support." -# If I put everyone here it messes with the "author" meta section. -authors = [{ name = "Notenlish", email = "notenlish@gmail.com" }] -maintainers = [ - # If I make it start with immelancholy for some reason it uses gallophostix's gmail for immelancholy. - { name = "Gallophostrix", email = "gallophostrix@gmail.com" }, - { name = "Immelancholy" }, - { name = "Notenlish", email = "notenlish@gmail.com" }, -] + name = "anifetch-cli" + classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Topic :: Terminals", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS", + ] + keywords = [ + "fetch", + "terminal", + "cli", + "animated", + "neofetch", + "fastfetch", + "anifetch", + ] + version = "1.0.5" + description = "Animated terminal fetch with video and audio support." + # If I put everyone here it messes with the "author" meta section. + authors = [{ name = "Notenlish", email = "notenlish@gmail.com" }] + maintainers = [ + # If I make it start with immelancholy for some reason it uses gallophostix's gmail for immelancholy. + { name = "Gallophostrix", email = "gallophostrix@gmail.com" }, + { name = "Immelancholy" }, + { name = "Notenlish", email = "notenlish@gmail.com" }, + ] -readme = "README.md" -license = { text = "MIT" } -requires-python = ">=3.11" -dependencies = ["platformdirs", "wcwidth", "rich", "pynput"] + readme = "README.md" + license = { text = "MIT" } + requires-python = ">=3.11" + dependencies = [ + "platformdirs==4.5.1", + "wcwidth==0.2.14", + "rich==14.3.1", + "pynput==1.8.1", + # "regex==2026.5.9" + ] [project.urls] -Homepage = "https://github.com/Notenlish/anifetch" -Issues = "https://github.com/Notenlish/anifetch/issues" + Homepage = "https://github.com/Notenlish/anifetch" + Issues = "https://github.com/Notenlish/anifetch/issues" [project.scripts] -anifetch = "anifetch.__init__:main" -anifetch-cli = "anifetch.__init__:main" + anifetch = "anifetch.__init__:main" + anifetch-cli = "anifetch.__init__:main" [tool.setuptools] -packages = ["anifetch"] -package-dir = { "" = "src" } + packages = ["anifetch"] + package-dir = { "" = "src" } [tool.setuptools.package-data] -anifetch = ["assets/**/*"] + anifetch = ["assets/**/*"] diff --git a/src/anifetch/ansi2txt.py b/src/anifetch/ansi2txt.py new file mode 100644 index 0000000..f24d43a --- /dev/null +++ b/src/anifetch/ansi2txt.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python + +# SPDX-License-Identifier: MIT AND AGPL-3.0-only + +# Original code from https://github.com/mmlb/ansi2txt + + +def ansi2txt(text: str) -> str: + EOF = "" + pos = 0 + output = [] + + def getchar(): + nonlocal pos + if pos >= len(text): + return EOF + ch = text[pos] + pos += 1 + return ch + + ch = None + + while ch != EOF: + ch = getchar() + + while ch == "\r": + ch = getchar() + if ch != "\n": + output.append("\r") + + if ch == "\x1b": + ch = getchar() + + if ch == "[": + ch = getchar() + while ch == ";" or ("0" <= ch <= "9") or ch == "?": + ch = getchar() + + elif ch == "]": + ch = getchar() + if ch != EOF and "0" <= ch <= "9": + while True: + ch = getchar() + if ch == EOF or ord(ch) == 7: + break + elif ch == "\x1b": + ch = getchar() + break + + elif ch == "%": + ch = getchar() + + elif ch != EOF: + output.append(ch) + + return "".join(output) diff --git a/src/anifetch/ansi_process.py b/src/anifetch/ansi_process.py new file mode 100644 index 0000000..758bf91 --- /dev/null +++ b/src/anifetch/ansi_process.py @@ -0,0 +1,226 @@ +from typing import Literal +import re +from .utils import printable_len +import wcwidth + + +_ANSI_RE = re.compile(r"(?:\x1B\[|\x9B)[\d;]*[A-Za-z]") + + +def _active_ansi_state(line: str, up_to: int) -> str: + """ + Return all ANSI SGR (color/style) sequences active just before Python + position `up_to`. Prepending this to a tail restores the correct color + state even when the preceding text contains an ANSI reset. + """ + return "".join( + m.group() + for m in _ANSI_RE.finditer(line, 0, up_to) + if m.group()[-1] == "m" # only SGR codes affect color/style + ) + + +def _col_to_char(line: str, col: int) -> int: + """ + Return the Python string index where visual column *col* begins in *line*. + ANSI sequences are zero-width; double-width characters (emoji, CJK) count as 2. + Returns len(line) if *col* exceeds the visual width of the string. + """ + i, c = 0, 0 + while i < len(line): + if c >= col: + break + m = _ANSI_RE.match(line, i) + if m: + i = m.end() # ANSI sequences are invisible aka: skip, don't count + else: + w = wcwidth.wcwidth(line[i]) + c += max(w, 0) # wcwidth returns -1 for non-printable + i += 1 + return i + + +class Token: + def __init__( + self, + type_: Literal[ + "text", + "go_left", + "go_right", + "go_to_column", + "sgr", + "save_cursor", + "restore_cursor", + "erase_line", + ], + value, + ) -> None: + self.type = type_ + self.value: str | int = value + + def __repr__(self) -> str: + if self.type == "text": + # pyrefly: ignore [bad-return] + return self.value + else: + return f"<{self.type} {self.value}>" + + +def strip_ansi_colors(lines: list[str]) -> list[str]: + ANSI_COLOR_RE = re.compile(r"\x1B\[[0-9;]*m") + return [ANSI_COLOR_RE.sub("", line) for line in lines] + + +def tokenize_lines(lines: list[str]): + # lines = ["lllllll lllllll╔════════════════════════════════════════════════════════════════════════════════════════════════════╗ root@debian 💻 ","║║ kernel > 6.12.1"] + # pattern = r"(?:\x1B\[|\x9B)\d*(?:;\d*)*[A-FfHhsu]" + # pattern = r"(?:\x1B\[|\x9B)\d*(?:;\d*)*[A-GHfhsum]" + pattern = r"\r|(?:\x1B\[|\x9B)[\d;]*[A-GHKfhsu]" # don't include 'm' in this + + line_tokens_all: list[list[Token]] = [] + + for line in lines: + line_tokens: list[Token] = [] + text_index = 0 + + for match in re.finditer(pattern, line): + start, end = match.span() + + # if there is text before the escape sequence + if start > text_index: + line_tokens.append(Token("text", line[text_index:start])) + + match_text = line[start:end] + + if match_text == "\r": + line_tokens.append(Token("go_to_column", 1)) + text_index = end + continue + + bracket = match_text.find("[") + # example: \x1b[100D + _left = (bracket + 1) if bracket != -1 else 1 + _right = len(match_text) - 1 + + params_str = match_text[_left:_right] + + # parse multi-param sequences (e.g. \x1b[5;10H → [5, 10]) + if params_str: + params = [int(x) if x else 1 for x in params_str.split(";")] + else: + params = [1] + + code = match_text[-1] + + if code == "C": + line_tokens.append(Token("go_right", params[0])) + elif code == "D": + line_tokens.append(Token("go_left", params[0])) + elif code == "G": + line_tokens.append(Token("go_to_column", params[0])) + elif code == "m": + # pass SGR through as a raw token so colours survive reconstruction + line_tokens.append(Token("sgr", match_text)) + elif code == "s": + line_tokens.append(Token("save_cursor", 0)) + elif code == "u": + line_tokens.append(Token("restore_cursor", 0)) + elif code == "K": + # \x1b[K and \x1b[0K both mean "clear to end of line" + amount = int(params_str) if params_str.isdigit() else 0 + line_tokens.append(Token("erase_line", amount)) + + text_index = end + + # remaining text after the last escape sequence + if text_index < len(line): + line_tokens.append(Token("text", line[text_index:])) + + line_tokens_all.append(line_tokens) + return line_tokens_all + + +def expand_ansi_movement_seq(lines: list[str]): + line_tokens_all = tokenize_lines(lines) + + result: list[str] = [] + + for line_tokens in line_tokens_all: + line = "" + cur_col = 0 # visual terminal column + cur_char = 0 # string index into `line` + saved_col = 0 # for \x1b[s / \x1b[u + + for token in line_tokens: + if token.type == "text": + text: str = token.value # type: ignore[assignment] + vis_len = printable_len(text) + + if cur_char > len(line): + line += " " * (cur_char - len(line)) + + tail_char = _col_to_char(line, cur_col + vis_len) + + # Collect the SGR state active at tail_char so that border + # characters in the tail keep their original colour even when + # the inserted text ends with \x1b[0m. + state = _active_ansi_state(line, tail_char) + + line = line[:cur_char] + text + state + line[tail_char:] + cur_col += vis_len + cur_char += len(text) + len(state) + + elif token.type == "go_right": + wanted_col = cur_col + token.value # type: ignore[operator] + needed = max(wanted_col - printable_len(line), 0) + line += " " * needed + cur_col = wanted_col + cur_char = _col_to_char(line, cur_col) + + elif token.type == "go_left": + wanted_col = cur_col - token.value # type: ignore[operator] + if wanted_col < 0: + needed = -wanted_col + line = " " * needed + line + cur_col = 0 + cur_char = 0 + else: + cur_col = wanted_col + cur_char = _col_to_char(line, cur_col) + + elif token.type == "go_to_column": + # escape seq is 1-based, convert to 0-based + wanted_col = token.value - 1 # type: ignore[operator] + needed = max(wanted_col - printable_len(line), 0) + if needed: + line += " " * needed + cur_col = wanted_col + cur_char = _col_to_char(line, cur_col) + + elif token.type == "sgr": + raw: str = token.value # type: ignore[assignment] + line = line[:cur_char] + raw + line[cur_char:] + cur_char += len(raw) + + elif token.type == "save_cursor": + saved_col = cur_col + + elif token.type == "restore_cursor": + cur_col = saved_col + cur_char = _col_to_char(line, cur_col) + + elif token.type == "erase_line": + amount = token.value + if amount == 0: + line = line[:cur_char] + elif amount == 1: + line = " " * cur_char + line[cur_char:] + elif amount == 2: + line = "" + cur_col = 0 + cur_char = 0 + + result.append(line) + + # print("\n".join(result)) + return result diff --git a/src/anifetch/ansi_process2.py b/src/anifetch/ansi_process2.py new file mode 100644 index 0000000..f9caaee --- /dev/null +++ b/src/anifetch/ansi_process2.py @@ -0,0 +1,199 @@ +import re +import regex +from wcwidth import wcswidth +from dataclasses import dataclass +from .utils import debug_write_str + +# this is slower but more readable, don't use this. +# this is because im using \X to get graphemes +# creating Cell objects +# wcswidth(maybe?) +# and using regex? I'd assume 're' is faster + + +@dataclass +class Token: + pass + + +@dataclass +class TextToken(Token): + text: str + + +@dataclass +class SGRToken(Token): # coloring stuff + params: list[int] + + +@dataclass +class GoLeftToken(Token): + amount: int + + +@dataclass +class GoToColumn(Token): + index: int # 0 based + + +@dataclass +class GoRightToken(Token): + amount: int + + +GENERAL_ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") +GENERAL_ANSI_REGEX = regex.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") +COLOR_ANSI_RE = re.compile(r"\x1B\[[0-9;]*m") + + +class Cell: + # modifier = ANSI coloring sequences + # text = a piece of text + # this could be an emoji, or just a normal piece of string. + # width = how many cells this would visually occupy + def __init__(self, text: str, modifier: str, width: int = 1): + self.text = text + self.modifier = modifier + self.width = width + + +@dataclass +class CellMap: + """2D array of cells.""" + + lines: list[list[Cell]] + + +def tokenize(raw_lines: list[str]): + """Tokenize raw string, to be later used to create a CellMap""" + tokens_per_line: list[list[Token]] = [] + + for line in raw_lines: + tokens: list[Token] = [] + text_index = 0 + + for match in regex.finditer(GENERAL_ANSI_REGEX, line): + start, end = match.span() + + if start > text_index: + tokens.append(TextToken(line[text_index:start])) + + match_text = line[start:end] + + bracket = match_text.find("[") + # example: \x1b[100D + _left = (bracket + 1) if bracket != -1 else 1 + _right = len(match_text) - 1 + + amount_str = match_text[_left:_right] + + code = match_text[-1].upper() + + if code in ("C", "D", "G"): + try: + amount = int(amount_str) if amount_str else 1 + except ValueError: + amount = 1 + if code == "C": + tokens.append(GoRightToken(amount)) + elif code == "D": + tokens.append(GoLeftToken(amount)) + elif code == "G": + tokens.append( + GoToColumn(amount - 1) + ) # escape seq is 1 based, convert to 0 based + + # TODO: also check for ANSI formatting string bs + if code == "M": + if amount_str: + amount_values = [int(v) for v in amount_str.split(";")] + else: + amount_values = [0] + tokens.append(SGRToken(amount_values)) + debug_write_str(f"shitty sgr ansi coloring shit {amount_str} \n") + + text_index = end + + # check if + if text_index < len(line): + tokens.append(TextToken(line[text_index:])) + + tokens_per_line.append(tokens) + + return tokens_per_line + + +def split_to_cells(tokenised_lines: list[list[Token]]): + grapheme_pattern = regex.compile(r"\X") + + cellmap = CellMap([]) + for line_tokens in tokenised_lines: + cells: list[Cell] = [] + current_modifier = "" + cell_index = 0 + for token in line_tokens: + if isinstance(token, TextToken): + graphemes = grapheme_pattern.findall(token.text) + for g in graphemes: + # g: str + width = wcswidth(g) + if width == 1: + # Extend if needed, then overwrite + while len(cells) <= cell_index: + cells.append(Cell(" ", modifier="")) + cells[cell_index] = Cell(g, modifier=current_modifier) + cell_index += 1 + elif width == 2: + while len(cells) <= cell_index + 1: + cells.append(Cell(" ", modifier="")) + cells[cell_index] = Cell(g, modifier=current_modifier, width=2) + cells[cell_index + 1] = Cell( + "", modifier="" + ) # continuation cell + cell_index += 2 + else: + raise Exception(f"Invalid width {width} {g}") + # current_modifier = "" # HUHHHH if I just disable it it gets better + if isinstance(token, SGRToken): + debug_write_str(f"skibidi skibidi the sgrtoken is here guys {token}\n") + # example: \x1b[1;31m + # depending on whether there is '=' or '+=' there the formatting can be fucked + if token.params == [0]: # reset + current_modifier = "\x1b[0m" + else: # add + current_modifier += ( + "\x1b[" + ";".join([str(v) for v in token.params]) + "m" + ) + if isinstance(token, GoRightToken): + wanted_cell_index = cell_index + token.amount + needed = max(wanted_cell_index - len(cells), 0) + for _ in range(needed): + cells.append(Cell(" ", modifier="")) + cell_index = wanted_cell_index + if isinstance(token, GoLeftToken): + wanted_cell_index = cell_index - token.amount + if wanted_cell_index < 0: + needed = -wanted_cell_index + for _ in range(needed): + cells.insert(0, Cell(" ", modifier="")) + cell_index = 0 + else: + cell_index = wanted_cell_index + cellmap.lines.append(cells) + return cellmap + + +def expand_ansi_movement_seq2(lines: list[str]): + debug_write_str("---------------\n") + cellmap = split_to_cells(tokenize(lines)) + out = [] + for line in cellmap.lines: + out_line = "" + for cell in line: + out_line += cell.modifier + cell.text + out.append(out_line) + out[-1] += "\x1b[0m" # reset + + # debug_write_str("\n".join(out)) + print("\n".join(out)) + return out diff --git a/src/anifetch/cli.py b/src/anifetch/cli.py index 033b90e..8e9f374 100644 --- a/src/anifetch/cli.py +++ b/src/anifetch/cli.py @@ -147,7 +147,7 @@ "--interval", required=False, type=float, - help="Set fetch refresh interval in seconds. Default is -1(never).", + help="Set fetch update interval in seconds. Default is -1(never). ", default=-1, ) parser.add_argument( diff --git a/src/anifetch/core.py b/src/anifetch/core.py index 2594cca..fe4976a 100644 --- a/src/anifetch/core.py +++ b/src/anifetch/core.py @@ -2,6 +2,9 @@ Anifetch core module for running the animation. """ +from .ansi_process import expand_ansi_movement_seq + +# from .ansi_process2 import expand_ansi_movement_seq2 import json import os import pathlib @@ -258,12 +261,16 @@ def run_anifetch(args): HEIGHT = args.height # Get the fetch output(neofetch/fastfetch) - fetch_output: list[str] = get_fetch_output( + fetch_lines: list[str] = get_fetch_output( not args.neofetch, neofetch_status, args.force, args.config ) + # fetch_output = strip_ansi_colors(fetch_output) # if I strip ansi colors the output is nearly the same as fastfetch + # s = time.perf_counter() + fetch_lines = expand_ansi_movement_seq(fetch_lines) + # e = time.perf_counter() + # print(e-s) + # raise SystemExit - # copy fetch_output to fetch_lines - fetch_lines: list[str] = fetch_output[:] len_fetch = len(fetch_lines) # put cached frames here @@ -368,7 +375,7 @@ def run_anifetch(args): chafa_args, args.center, len_fetch, - fetch_output, + fetch_lines, ) futures.append(future) # if wanted aspect ratio doesnt match source, chafa makes width as high as it can, and adjusts height accordingly. @@ -394,7 +401,7 @@ def run_anifetch(args): pad = (len_chafa - len_fetch) // 2 remind = (len_chafa - len_fetch) % 2 fetch_lines = ( - [" " * WIDTH] * pad + fetch_output + [" " * WIDTH] * (pad + remind) + [" " * WIDTH] * pad + fetch_lines + [" " * WIDTH] * (pad + remind) ) HEIGHT = len(frames[0].splitlines()) @@ -476,6 +483,7 @@ def run_anifetch(args): args.loop, args.cleanup, args.no_key_exit, + args.config, len_chafa or None, WIDTH, GAP, diff --git a/src/anifetch/renderer.py b/src/anifetch/renderer.py index 52cdb10..569971d 100644 --- a/src/anifetch/renderer.py +++ b/src/anifetch/renderer.py @@ -10,6 +10,9 @@ clear_screen_soft, get_terminal_width, ) +from .ansi_process import expand_ansi_movement_seq + +# from .ansi_process2 import expand_ansi_movement_seq2 import subprocess from .keyreader import KeyReader from typing import Literal @@ -19,7 +22,7 @@ from rich.text import Text from rich.console import Console from rich.align import Align - +# import re # import logging # logger = logging.getLogger(__name__) @@ -70,6 +73,7 @@ def __init__( loop: int, cleanup: bool, no_key_exit: bool, + config, len_chafa: int | None, width: int, gap: int, @@ -102,6 +106,7 @@ def __init__( self.loop: int = loop self.cleanup: bool = cleanup self.no_key_exit: bool = no_key_exit + self.config = config self.len_chafa: int | None = len_chafa self.width: int = width self.gap: int = gap @@ -161,8 +166,12 @@ def _(): if self.stop_fetch_thread: return fetch_output: list[str] = get_fetch_output( - self.use_fastfetch, self.neofetch_status, self.force_neofetch + self.use_fastfetch, + self.neofetch_status, + self.force_neofetch, + self.config, ) + fetch_output = expand_ansi_movement_seq(fetch_output) if self.stop_fetch_thread: return diff --git a/src/anifetch/utils.py b/src/anifetch/utils.py index e6d0958..95b57a4 100644 --- a/src/anifetch/utils.py +++ b/src/anifetch/utils.py @@ -155,6 +155,11 @@ def clean_ansi(raw_text: str): return ANSI_RE.sub("", raw_text) +def strip_ansi(text): + ansi_escape = re.compile(r"\x1b\[[0-9;]*m") # color only + return ansi_escape.sub("", text) + + def get_character_width(raw: str): """Gives the raw terminal width of a particular string by stripping ANSI codes, removing \n \t \r and using wcwidth to get the actual character width.""" return wcwidth.wcswidth( @@ -226,11 +231,6 @@ def normal_print(should_print: bool, *msg): print(*msg) -def strip_ansi(text): - ansi_escape = re.compile(r"\x1b\[[0-9;]*m") - return ansi_escape.sub("", text) - - def get_text_length_of_formatted_text(text: str): text = strip_ansi(text) return len(text) @@ -711,3 +711,27 @@ def split_to_frames(args, CACHE_PATH, IS_TRANSPARENT, stdout, stderr): stderr=stderr, text=True, ) + + +def printable_len(raw: str): + """Returns printable length of the string.""" + cleaned = clean_ansi(raw) + w = wcwidth.wcswidth(cleaned) + + # it can return -1, so if it does return -1 do per character + return w if w >= 0 else sum(max(wcwidth.wcwidth(c), 0) for c in cleaned) + + +def debug_write_str(t: str): + with open("debug.ignore", "a", encoding="utf-8") as f: + f.write(t) + + +def overwrite_string(original_text: str, index: int, text_to_overwrite: str): + out = ( + original_text[:index] + + text_to_overwrite + + original_text[index + printable_len(text_to_overwrite) :] + ) + + return out