neofetch_util.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. from __future__ import annotations
  2. import inspect
  3. import os
  4. import platform
  5. import re
  6. import shlex
  7. import subprocess
  8. import sys
  9. import zipfile
  10. from dataclasses import dataclass
  11. from pathlib import Path
  12. from subprocess import check_output
  13. from tempfile import TemporaryDirectory
  14. from urllib.request import urlretrieve
  15. import pkg_resources
  16. from typing_extensions import Literal
  17. from hyfetch.color_util import color
  18. from .constants import GLOBAL_CFG, MINGIT_URL
  19. from .presets import ColorProfile
  20. from .serializer import from_dict
  21. RE_NEOFETCH_COLOR = re.compile('\\${c[0-9]}')
  22. def term_size() -> tuple[int, int]:
  23. """
  24. Get terminal size
  25. :return:
  26. """
  27. try:
  28. return os.get_terminal_size().columns, os.get_terminal_size().lines
  29. except Exception:
  30. return 100, 20
  31. def ascii_size(asc: str) -> tuple[int, int]:
  32. """
  33. Get distro ascii width, height ignoring color code
  34. :param asc: Distro ascii
  35. :return: Width, Height
  36. """
  37. return max(len(line) for line in re.sub(RE_NEOFETCH_COLOR, '', asc).split('\n')), len(asc.split('\n'))
  38. def normalize_ascii(asc: str) -> str:
  39. """
  40. Make sure every line are the same width
  41. """
  42. w = ascii_size(asc)[0]
  43. return '\n'.join(line + ' ' * (w - ascii_size(line)[0]) for line in asc.split('\n'))
  44. def fill_starting(asc: str) -> str:
  45. """
  46. Fill the missing starting placeholders.
  47. E.g. "${c1}...\n..." -> "${c1}...\n${c1}..."
  48. """
  49. new = []
  50. last = ''
  51. for line in asc.split('\n'):
  52. new.append(last + line)
  53. # Line has color placeholders
  54. matches = RE_NEOFETCH_COLOR.findall(line)
  55. if len(matches) > 0:
  56. # Get the last placeholder for the next line
  57. last = matches[-1]
  58. return '\n'.join(new)
  59. @dataclass
  60. class ColorAlignment:
  61. mode: Literal['horizontal', 'vertical', 'custom']
  62. # custom_colors[ascii color index] = unique color index in preset
  63. custom_colors: dict[int, int] = ()
  64. # Foreground/background ascii color index
  65. fore_back: tuple[int, int] = ()
  66. @classmethod
  67. def from_dict(cls, d: dict):
  68. return from_dict(cls, d)
  69. def recolor_ascii(self, asc: str, preset: ColorProfile) -> str:
  70. """
  71. Use the color alignment to recolor an ascii art
  72. :return Colored ascii, Uncolored lines
  73. """
  74. asc = fill_starting(asc)
  75. if self.fore_back and self.mode in ['horizontal', 'vertical']:
  76. fore, back = self.fore_back
  77. # Replace foreground colors
  78. asc = asc.replace(f'${{c{fore}}}', color('&0' if GLOBAL_CFG.is_light else '&f'))
  79. lines = asc.split('\n')
  80. # Add new colors
  81. if self.mode == 'horizontal':
  82. colors = preset.with_length(len(lines))
  83. asc = '\n'.join([l.replace(f'${{c{back}}}', colors[i].to_ansi()) + color('&r') for i, l in enumerate(lines)])
  84. else:
  85. raise NotImplementedError()
  86. # Remove existing colors
  87. asc = re.sub(RE_NEOFETCH_COLOR, '', asc)
  88. elif self.mode in ['horizontal', 'vertical']:
  89. # Remove existing colors
  90. asc = re.sub(RE_NEOFETCH_COLOR, '', asc)
  91. lines = asc.split('\n')
  92. # Add new colors
  93. if self.mode == 'horizontal':
  94. colors = preset.with_length(len(lines))
  95. asc = '\n'.join([colors[i].to_ansi() + l + color('&r') for i, l in enumerate(lines)])
  96. else:
  97. asc = '\n'.join(preset.color_text(line) + color('&r') for line in lines)
  98. else:
  99. preset = preset.unique_colors()
  100. # Apply colors
  101. color_map = {ai: preset.colors[pi].to_ansi() for ai, pi in self.custom_colors.items()}
  102. for ascii_i, c in color_map.items():
  103. asc = asc.replace(f'${{c{ascii_i}}}', c)
  104. return asc
  105. def get_command_path() -> str:
  106. """
  107. Get the absolute path of the neofetch command
  108. :return: Command path
  109. """
  110. cmd_path = pkg_resources.resource_filename(__name__, 'scripts/neowofetch')
  111. # Windows doesn't support symbolic links, but also I can't detect symbolic links... hard-code it here for now.
  112. if platform.system() == 'Windows':
  113. return str(Path(cmd_path).parent.parent.parent / 'neofetch')
  114. return cmd_path
  115. def ensure_git_bash() -> Path:
  116. """
  117. Ensure git bash installation for windows
  118. :returns git bash path
  119. """
  120. if platform.system() == 'Windows':
  121. # Find installation in default path
  122. def_path = Path(r'C:\Program Files\Git\bin\bash.exe')
  123. if def_path.is_file():
  124. return def_path
  125. # Find installation in PATH (C:\Program Files\Git\cmd should be in path)
  126. pth = (os.environ.get('PATH') or '').lower().split(';')
  127. pth = [p for p in pth if p.endswith(r'\git\cmd')]
  128. if pth:
  129. return Path(pth[0]).parent / r'bin\bash.exe'
  130. # Previously downloaded portable installation
  131. path = Path(__file__).parent / 'min_git'
  132. pkg_path = path / 'package.zip'
  133. if path.is_dir():
  134. return path / r'bin\bash.exe'
  135. # No installation found, download a portable installation
  136. print('Git installation not found. Git is required to use HyFetch/neofetch on Windows')
  137. print('Downloading a minimal portable package for Git...')
  138. urlretrieve(MINGIT_URL, pkg_path)
  139. print('Download finished! Extracting...')
  140. with zipfile.ZipFile(pkg_path, 'r') as zip_ref:
  141. zip_ref.extractall(path)
  142. print('Done!')
  143. return path / r'bin\bash.exe'
  144. def check_windows_cmd():
  145. """
  146. Check if this script is running under cmd.exe. If so, launch an external window with git bash
  147. since cmd doesn't support RGB colors.
  148. """
  149. if platform.system() == 'Windows':
  150. import psutil
  151. # TODO: This line does not correctly identify cmd prompts...
  152. if psutil.Process(os.getppid()).name().lower().strip() == 'cmd.exe':
  153. print("cmd.exe doesn't support RGB colors, restarting in MinTTY...")
  154. cmd = f'"{ensure_git_bash().parent.parent / "usr/bin/mintty.exe"}" -s 110,40 -e python -m hyfetch --ask-exit'
  155. os.system(cmd)
  156. sys.exit(0)
  157. def run_command(args: str, pipe: bool = False) -> str | None:
  158. """
  159. Run neofetch command
  160. """
  161. if platform.system() != 'Windows':
  162. full_cmd = shlex.split(f'/usr/bin/env bash {get_command_path()} {args}')
  163. else:
  164. cmd = get_command_path().replace("\\", "/").replace("C:/", "/c/")
  165. args = args.replace('\\', '/').replace('C:/', '/c/')
  166. full_cmd = [ensure_git_bash(), '-c', f'{cmd} {args}']
  167. # print(full_cmd)
  168. if pipe:
  169. return check_output(full_cmd).decode().strip()
  170. else:
  171. subprocess.run(full_cmd)
  172. def get_distro_ascii(distro: str | None = None) -> str:
  173. """
  174. Get the distro ascii of the current distro. Or if distro is specified, get the specific distro's
  175. ascii art instead.
  176. :return: Distro ascii
  177. """
  178. if not distro and GLOBAL_CFG.override_distro:
  179. distro = GLOBAL_CFG.override_distro
  180. if GLOBAL_CFG.debug:
  181. print(distro)
  182. print(GLOBAL_CFG)
  183. cmd = 'print_ascii'
  184. if distro:
  185. os.environ['CUSTOM_DISTRO'] = distro
  186. cmd = 'print_custom_ascii'
  187. asc = run_command(cmd, True)
  188. # Unescape backslashes here because backslashes are escaped in neofetch for printf
  189. asc = asc.replace('\\\\', '\\')
  190. return normalize_ascii(asc)
  191. def get_distro_name():
  192. return run_command('ascii_distro_name', True)
  193. def run_neofetch(preset: ColorProfile, alignment: ColorAlignment):
  194. """
  195. Run neofetch with colors
  196. :param preset: Color palette
  197. :param alignment: Color alignment settings
  198. """
  199. asc = get_distro_ascii()
  200. w, h = ascii_size(asc)
  201. asc = alignment.recolor_ascii(asc, preset)
  202. # Escape backslashes here because backslashes are escaped in neofetch for printf
  203. asc = asc.replace('\\', '\\\\')
  204. # Write temp file
  205. with TemporaryDirectory() as tmp_dir:
  206. tmp_dir = Path(tmp_dir)
  207. path = tmp_dir / 'ascii.txt'
  208. path.write_text(asc)
  209. # Call neofetch with the temp file
  210. os.environ['ascii_len'] = str(w)
  211. os.environ['ascii_lines'] = str(h)
  212. run_command(f'--ascii --source {path.absolute()} --ascii-colors')
  213. def get_fore_back(distro: str | None = None) -> tuple[int, int] | None:
  214. """
  215. Get recommended foreground-background configuration for distro, or None if the distro ascii is
  216. not suitable for fore-back configuration.
  217. :return:
  218. """
  219. if not distro and GLOBAL_CFG.override_distro:
  220. distro = GLOBAL_CFG.override_distro
  221. if not distro:
  222. distro = get_distro_name().lower()
  223. distro = distro.lower().replace(' ', '-')
  224. for k, v in fore_back.items():
  225. if distro == k.lower():
  226. return v
  227. return None
  228. # Foreground-background recommendation
  229. fore_back = {
  230. 'fedora': (2, 1),
  231. 'ubuntu': (2, 1),
  232. 'kubuntu': (2, 1),
  233. 'lubuntu': (2, 1),
  234. 'xubuntu': (2, 1),
  235. 'ubuntu-cinnamon': (2, 1),
  236. 'ubuntu-kylin': (2, 1),
  237. 'ubuntu-mate': (2, 1),
  238. 'ubuntu-studio': (2, 1),
  239. 'ubuntu-sway': (2, 1),
  240. }