neofetch_util.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  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 zipfile
  9. from dataclasses import dataclass
  10. from pathlib import Path
  11. from subprocess import check_output
  12. from tempfile import TemporaryDirectory
  13. from urllib.request import urlretrieve
  14. import pkg_resources
  15. import psutil
  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. return pkg_resources.resource_filename(__name__, 'scripts/neowofetch')
  111. def ensure_git_bash() -> Path:
  112. """
  113. Ensure git bash installation for windows
  114. :returns git bash path
  115. """
  116. if platform.system() == 'Windows':
  117. # Find installation in default path
  118. def_path = Path(r'C:\Program Files\Git\bin\bash.exe')
  119. if def_path.is_file():
  120. return def_path
  121. # Find installation in PATH (C:\Program Files\Git\cmd should be in path)
  122. pth = (os.environ.get('PATH') or '').lower().split(';')
  123. pth = [p for p in pth if p.endswith(r'\git\cmd')]
  124. if pth:
  125. return Path(pth[0]).parent / r'bin\bash.exe'
  126. # Previously downloaded portable installation
  127. path = Path(__file__).parent / 'min_git'
  128. pkg_path = path / 'package.zip'
  129. if path.is_dir():
  130. return path / r'bin\bash.exe'
  131. # No installation found, download a portable installation
  132. print('Git installation not found. Git is required to use HyFetch/neofetch on Windows')
  133. print('Downloading a minimal portable package for Git...')
  134. urlretrieve(MINGIT_URL, pkg_path)
  135. print('Download finished! Extracting...')
  136. with zipfile.ZipFile(pkg_path, 'r') as zip_ref:
  137. zip_ref.extractall(path)
  138. print('Done!')
  139. return path / r'bin\bash.exe'
  140. def check_windows_cmd():
  141. """
  142. Check if this script is running under cmd.exe. If so, launch an external window with git bash
  143. since cmd doesn't support RGB colors.
  144. """
  145. if psutil.Process(os.getppid()).name().lower().strip() == 'cmd.exe':
  146. print("cmd.exe doesn't support RGB colors, restarting in MinTTY...")
  147. cmd = f'"{ensure_git_bash().parent.parent / "usr/bin/mintty.exe"}" -s 110,40 -e python -m hyfetch --ask-exit'
  148. os.system(cmd)
  149. exit()
  150. def run_command(args: str, pipe: bool = False) -> str | None:
  151. """
  152. Run neofetch command
  153. """
  154. if platform.system() != 'Windows':
  155. full_cmd = shlex.split(f'{get_command_path()} {args}')
  156. else:
  157. cmd = get_command_path().replace("\\", "/").replace("C:/", "/c/")
  158. args = args.replace('\\', '/').replace('C:/', '/c/')
  159. full_cmd = [ensure_git_bash(), '-c', f'{cmd} {args}']
  160. # print(full_cmd)
  161. if pipe:
  162. return check_output(full_cmd).decode().strip()
  163. else:
  164. subprocess.run(full_cmd)
  165. def get_distro_ascii(distro: str | None = None) -> str:
  166. """
  167. Get the distro ascii of the current distro. Or if distro is specified, get the specific distro's
  168. ascii art instead.
  169. :return: Distro ascii
  170. """
  171. if not distro and GLOBAL_CFG.override_distro:
  172. distro = GLOBAL_CFG.override_distro
  173. if GLOBAL_CFG.debug:
  174. print(distro)
  175. print(GLOBAL_CFG)
  176. cmd = 'print_ascii'
  177. if distro:
  178. os.environ['CUSTOM_DISTRO'] = distro
  179. cmd = 'print_custom_ascii'
  180. return normalize_ascii(run_command(cmd, True))
  181. def get_distro_name():
  182. return run_command('ascii_distro_name', True)
  183. def run_neofetch(preset: ColorProfile, alignment: ColorAlignment):
  184. """
  185. Run neofetch with colors
  186. :param preset: Color palette
  187. :param alignment: Color alignment settings
  188. """
  189. asc = get_distro_ascii()
  190. w, h = ascii_size(asc)
  191. asc = alignment.recolor_ascii(asc, preset)
  192. # Write temp file
  193. with TemporaryDirectory() as tmp_dir:
  194. tmp_dir = Path(tmp_dir)
  195. path = tmp_dir / 'ascii.txt'
  196. path.write_text(asc)
  197. # Call neofetch with the temp file
  198. os.environ['ascii_len'] = str(w)
  199. os.environ['ascii_lines'] = str(h)
  200. run_command(f'--ascii --source {path.absolute()} --ascii-colors')
  201. def get_fore_back(distro: str | None = None) -> tuple[int, int] | None:
  202. """
  203. Get recommended foreground-background configuration for distro, or None if the distro ascii is
  204. not suitable for fore-back configuration.
  205. :return:
  206. """
  207. if not distro and GLOBAL_CFG.override_distro:
  208. distro = GLOBAL_CFG.override_distro
  209. if not distro:
  210. distro = get_distro_name().lower()
  211. for k, v in fore_back.items():
  212. if distro.startswith(k.lower()):
  213. return v
  214. return None
  215. # Foreground-background recommendation
  216. fore_back = {
  217. 'fedora': (2, 1),
  218. 'ubuntu': (2, 1),
  219. }