nikofetch 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nikofetch-0.1.0/LICENSE +21 -0
- nikofetch-0.1.0/PKG-INFO +79 -0
- nikofetch-0.1.0/README.md +56 -0
- nikofetch-0.1.0/pyproject.toml +38 -0
- nikofetch-0.1.0/setup.cfg +4 -0
- nikofetch-0.1.0/src/nikofetch/__init__.py +4 -0
- nikofetch-0.1.0/src/nikofetch/cli.py +531 -0
- nikofetch-0.1.0/src/nikofetch.egg-info/PKG-INFO +79 -0
- nikofetch-0.1.0/src/nikofetch.egg-info/SOURCES.txt +11 -0
- nikofetch-0.1.0/src/nikofetch.egg-info/dependency_links.txt +1 -0
- nikofetch-0.1.0/src/nikofetch.egg-info/entry_points.txt +2 -0
- nikofetch-0.1.0/src/nikofetch.egg-info/requires.txt +2 -0
- nikofetch-0.1.0/src/nikofetch.egg-info/top_level.txt +1 -0
nikofetch-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nikohalfkino
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
nikofetch-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nikofetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny fastfetch-style system info banner
|
|
5
|
+
Author: niko
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YOUR_USERNAME/nikofetch
|
|
8
|
+
Project-URL: Issues, https://github.com/YOUR_USERNAME/nikofetch/issues
|
|
9
|
+
Keywords: fetch,fastfetch,neofetch,system-info,cli,terminal
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Terminals
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: psutil>=5.9
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# nikofetch
|
|
25
|
+
|
|
26
|
+
A tiny fastfetch-style system info banner, written in Python.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
you@host Sat Jan 01, 12:00
|
|
30
|
+
|
|
31
|
+
OS: Linux 6.x
|
|
32
|
+
Kernel: 6.x
|
|
33
|
+
Shell: zsh
|
|
34
|
+
Terminal: kitty
|
|
35
|
+
Uptime: 2d 4h 12m
|
|
36
|
+
CPU: 12% @ 3.60 GHz
|
|
37
|
+
Memory: 8.2 GiB / 32.0 GiB (26%)
|
|
38
|
+
Disk: 120 GiB / 512 GiB (23%)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Minimal, configurable stat banner (OS, kernel, shell, terminal, uptime, CPU, memory, disk)
|
|
44
|
+
- Customizable titlebar with `{user}`, `{host}`, `{date}`, `{time}` placeholders
|
|
45
|
+
- Optional ASCII art or (in [kitty](https://sw.kovidgoyal.net/kitty/)) inline image art
|
|
46
|
+
- Interactive terminal config menu — no need to hand-edit JSON
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pipx install nikofetch
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
or
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install nikofetch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
nikofetch # show the banner
|
|
64
|
+
nikofetch --config # open the interactive config menu
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Config is stored at `~/.config/nikofetch/config.json`.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone https://github.com/YOUR_USERNAME/nikofetch
|
|
73
|
+
cd nikofetch
|
|
74
|
+
pip install -e .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# nikofetch
|
|
2
|
+
|
|
3
|
+
A tiny fastfetch-style system info banner, written in Python.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
you@host Sat Jan 01, 12:00
|
|
7
|
+
|
|
8
|
+
OS: Linux 6.x
|
|
9
|
+
Kernel: 6.x
|
|
10
|
+
Shell: zsh
|
|
11
|
+
Terminal: kitty
|
|
12
|
+
Uptime: 2d 4h 12m
|
|
13
|
+
CPU: 12% @ 3.60 GHz
|
|
14
|
+
Memory: 8.2 GiB / 32.0 GiB (26%)
|
|
15
|
+
Disk: 120 GiB / 512 GiB (23%)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Minimal, configurable stat banner (OS, kernel, shell, terminal, uptime, CPU, memory, disk)
|
|
21
|
+
- Customizable titlebar with `{user}`, `{host}`, `{date}`, `{time}` placeholders
|
|
22
|
+
- Optional ASCII art or (in [kitty](https://sw.kovidgoyal.net/kitty/)) inline image art
|
|
23
|
+
- Interactive terminal config menu — no need to hand-edit JSON
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pipx install nikofetch
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
or
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install nikofetch
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
nikofetch # show the banner
|
|
41
|
+
nikofetch --config # open the interactive config menu
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Config is stored at `~/.config/nikofetch/config.json`.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://github.com/YOUR_USERNAME/nikofetch
|
|
50
|
+
cd nikofetch
|
|
51
|
+
pip install -e .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nikofetch"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A tiny fastfetch-style system info banner"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "niko" } # <-- put your name / GitHub handle here
|
|
14
|
+
]
|
|
15
|
+
keywords = ["fetch", "fastfetch", "neofetch", "system-info", "cli", "terminal"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: End Users/Desktop",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Topic :: Terminals",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"psutil>=5.9",
|
|
27
|
+
"rich>=13.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/YOUR_USERNAME/nikofetch"
|
|
32
|
+
Issues = "https://github.com/YOUR_USERNAME/nikofetch/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
nikofetch = "nikofetch:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
nikofetch - a tiny fastfetch-style system info banner
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
nikofetch # display current banner
|
|
7
|
+
nikofetch --config # config menu
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import contextlib
|
|
12
|
+
import copy
|
|
13
|
+
import curses
|
|
14
|
+
import getpass
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import socket
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import termios
|
|
24
|
+
import tty
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import psutil
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from rich.table import Table
|
|
31
|
+
from rich.text import Text
|
|
32
|
+
|
|
33
|
+
console = Console()
|
|
34
|
+
|
|
35
|
+
CONFIG_DIR = Path.home() / ".config" / "nikofetch"
|
|
36
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
37
|
+
|
|
38
|
+
DEFAULT_CONFIG = {
|
|
39
|
+
"titlebar": {
|
|
40
|
+
"left": "{user}@{host}",
|
|
41
|
+
"center": "",
|
|
42
|
+
"right": "{date}, {time}",
|
|
43
|
+
},
|
|
44
|
+
"stats": {
|
|
45
|
+
"os": True,
|
|
46
|
+
"kernel": True,
|
|
47
|
+
"shell": True,
|
|
48
|
+
"terminal": True,
|
|
49
|
+
"uptime": True,
|
|
50
|
+
"cpu": True,
|
|
51
|
+
"memory": True,
|
|
52
|
+
"disk": True,
|
|
53
|
+
},
|
|
54
|
+
"art": {
|
|
55
|
+
"mode": "none", # none | ascii | image
|
|
56
|
+
"ascii_path": "",
|
|
57
|
+
"image_path": "",
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Config load/save
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config() -> dict:
|
|
67
|
+
if not CONFIG_PATH.exists():
|
|
68
|
+
return copy.deepcopy(DEFAULT_CONFIG)
|
|
69
|
+
try:
|
|
70
|
+
with open(CONFIG_PATH) as f:
|
|
71
|
+
loaded = json.load(f)
|
|
72
|
+
except (json.JSONDecodeError, OSError):
|
|
73
|
+
return copy.deepcopy(DEFAULT_CONFIG)
|
|
74
|
+
|
|
75
|
+
# merge with defaults so new keys added in future versions don't break old configs
|
|
76
|
+
merged = copy.deepcopy(DEFAULT_CONFIG)
|
|
77
|
+
for section, values in loaded.items():
|
|
78
|
+
if section in merged and isinstance(values, dict):
|
|
79
|
+
merged[section].update(values)
|
|
80
|
+
else:
|
|
81
|
+
merged[section] = values
|
|
82
|
+
return merged
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_config(config: dict):
|
|
86
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
with open(CONFIG_PATH, "w") as f:
|
|
88
|
+
json.dump(config, f, indent=2)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Stat getters
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_uptime() -> str:
|
|
96
|
+
boot = datetime.fromtimestamp(psutil.boot_time())
|
|
97
|
+
delta = datetime.now() - boot
|
|
98
|
+
days, rem = divmod(int(delta.total_seconds()), 86400)
|
|
99
|
+
hours, rem = divmod(rem, 3600)
|
|
100
|
+
minutes, _ = divmod(rem, 60)
|
|
101
|
+
parts = []
|
|
102
|
+
if days:
|
|
103
|
+
parts.append(f"{days}d")
|
|
104
|
+
if hours:
|
|
105
|
+
parts.append(f"{hours}h")
|
|
106
|
+
parts.append(f"{minutes}m")
|
|
107
|
+
return " ".join(parts)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_shell() -> str:
|
|
111
|
+
shell_path = os.environ.get("SHELL", "unknown")
|
|
112
|
+
return shell_path.split("/")[-1]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_terminal() -> str:
|
|
116
|
+
return os.environ.get("TERM_PROGRAM") or os.environ.get("TERM", "unknown")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_memory() -> str:
|
|
120
|
+
vm = psutil.virtual_memory()
|
|
121
|
+
used_gb = vm.used / (1024 ** 3)
|
|
122
|
+
total_gb = vm.total / (1024 ** 3)
|
|
123
|
+
return f"{used_gb:.1f} GiB / {total_gb:.1f} GiB ({vm.percent}%)"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_cpu() -> str:
|
|
127
|
+
freq = psutil.cpu_freq()
|
|
128
|
+
# interval=None measures since the last call, so this reads 0% on a
|
|
129
|
+
# fresh process (e.g. a one-shot `nikofetch` run). Pass a small
|
|
130
|
+
# interval (e.g. 0.1) if accuracy matters more than startup latency.
|
|
131
|
+
load = psutil.cpu_percent(interval=None)
|
|
132
|
+
if freq:
|
|
133
|
+
return f"{load:.0f}% @ {freq.current / 1000:.2f} GHz"
|
|
134
|
+
return f"{load:.0f}%"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_disk() -> str:
|
|
138
|
+
du = psutil.disk_usage("/")
|
|
139
|
+
used_gb = du.used / (1024 ** 3)
|
|
140
|
+
total_gb = du.total / (1024 ** 3)
|
|
141
|
+
return f"{used_gb:.0f} GiB / {total_gb:.0f} GiB ({du.percent}%)"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
STAT_DEFINITIONS = [
|
|
145
|
+
("os", "OS", lambda: f"{platform.system()} {platform.release()}"),
|
|
146
|
+
("kernel", "Kernel", lambda: platform.version().split()[0] if platform.version() else "n/a"),
|
|
147
|
+
("shell", "Shell", get_shell),
|
|
148
|
+
("terminal", "Terminal", get_terminal),
|
|
149
|
+
("uptime", "Uptime", get_uptime),
|
|
150
|
+
("cpu", "CPU", get_cpu),
|
|
151
|
+
("memory", "Memory", get_memory),
|
|
152
|
+
("disk", "Disk", get_disk),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Rendering
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def format_placeholders(text: str) -> str:
|
|
161
|
+
now = datetime.now()
|
|
162
|
+
return text.format(
|
|
163
|
+
user=getpass.getuser(),
|
|
164
|
+
host=socket.gethostname(),
|
|
165
|
+
date=now.strftime("%a %b %d"),
|
|
166
|
+
time=now.strftime("%H:%M"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_titlebar(config: dict, width: int) -> Text:
|
|
171
|
+
left = format_placeholders(config["titlebar"].get("left", "")).strip()
|
|
172
|
+
center = format_placeholders(config["titlebar"].get("center", "")).strip()
|
|
173
|
+
right = format_placeholders(config["titlebar"].get("right", "")).strip()
|
|
174
|
+
|
|
175
|
+
left = f" {left} " if left else ""
|
|
176
|
+
right = f" {right} " if right else ""
|
|
177
|
+
center = f" {center} " if center else ""
|
|
178
|
+
|
|
179
|
+
remaining = width - len(left) - len(right)
|
|
180
|
+
|
|
181
|
+
if center and remaining > len(center):
|
|
182
|
+
gap = remaining - len(center)
|
|
183
|
+
left_pad = gap // 2
|
|
184
|
+
right_pad = gap - left_pad
|
|
185
|
+
bar_text = f"{left}{' ' * left_pad}{center}{' ' * right_pad}{right}"
|
|
186
|
+
else:
|
|
187
|
+
pad = max(remaining, 0)
|
|
188
|
+
bar_text = f"{left}{' ' * pad}{right}"
|
|
189
|
+
|
|
190
|
+
bar_text = bar_text[:width] if width else bar_text
|
|
191
|
+
return Text(bar_text, style="black on white")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_stats_table(config: dict) -> Table:
|
|
195
|
+
table = Table.grid(padding=(0, 2))
|
|
196
|
+
table.add_column(justify="right", style="bold cyan")
|
|
197
|
+
table.add_column()
|
|
198
|
+
|
|
199
|
+
for key, label, getter in STAT_DEFINITIONS:
|
|
200
|
+
if config["stats"].get(key, True):
|
|
201
|
+
table.add_row(f"{label}:", getter())
|
|
202
|
+
return table
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_ascii_art(config: dict) -> str | None:
|
|
206
|
+
art = config.get("art", {})
|
|
207
|
+
if art.get("mode") != "ascii" or not art.get("ascii_path"):
|
|
208
|
+
return None
|
|
209
|
+
resolved = Path(art["ascii_path"]).expanduser()
|
|
210
|
+
if not resolved.exists():
|
|
211
|
+
console.print(f"[red]ASCII art file not found: {art['ascii_path']}[/red]")
|
|
212
|
+
return None
|
|
213
|
+
try:
|
|
214
|
+
return resolved.read_text()
|
|
215
|
+
except OSError as e:
|
|
216
|
+
console.print(f"[red]Couldn't read ASCII art file: {e}[/red]")
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
IMAGE_WIDTH_CELLS = 22 # how much horizontal space to reserve for the image
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@contextlib.contextmanager
|
|
224
|
+
def raw_terminal():
|
|
225
|
+
# puts stdin into raw mode for the block, restoring old settings after
|
|
226
|
+
fd = sys.stdin.fileno()
|
|
227
|
+
old_settings = termios.tcgetattr(fd)
|
|
228
|
+
try:
|
|
229
|
+
tty.setraw(fd)
|
|
230
|
+
yield fd
|
|
231
|
+
finally:
|
|
232
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _query_cursor_row() -> int:
|
|
236
|
+
with raw_terminal():
|
|
237
|
+
sys.stdout.write("\x1b[6n")
|
|
238
|
+
sys.stdout.flush()
|
|
239
|
+
buf = ""
|
|
240
|
+
while "R" not in buf:
|
|
241
|
+
buf += sys.stdin.read(1)
|
|
242
|
+
match = re.search(r"\x1b\[(\d+);(\d+)R", buf)
|
|
243
|
+
return int(match.group(1)) if match else 1
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def render_image_beside_stats(config: dict, stats_table: Table) -> bool:
|
|
247
|
+
"""Place the configured image at the cursor's current row using kitty's
|
|
248
|
+
--place (absolute screen coordinates), then manually cursor-position each
|
|
249
|
+
stats line to sit beside it. Returns True if it handled the image (even
|
|
250
|
+
if it fell back to printing an error), False if there's nothing to do."""
|
|
251
|
+
art = config.get("art", {})
|
|
252
|
+
if art.get("mode") != "image" or not art.get("image_path"):
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
image_path = art["image_path"]
|
|
256
|
+
if "KITTY_WINDOW_ID" not in os.environ:
|
|
257
|
+
console.print(
|
|
258
|
+
"[yellow]Image art configured, but this isn't a kitty terminal — skipping.[/yellow]"
|
|
259
|
+
)
|
|
260
|
+
return False
|
|
261
|
+
resolved = Path(image_path).expanduser()
|
|
262
|
+
if not resolved.exists():
|
|
263
|
+
console.print(f"[red]Image not found: {image_path}[/red]")
|
|
264
|
+
return False
|
|
265
|
+
if not shutil.which("kitten"):
|
|
266
|
+
console.print("[yellow]'kitten' command not found — can't display image.[/yellow]")
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
with console.capture() as capture:
|
|
270
|
+
console.print(stats_table)
|
|
271
|
+
lines = capture.get().rstrip("\n").split("\n")
|
|
272
|
+
|
|
273
|
+
image_height = max(len(lines), 6)
|
|
274
|
+
row = _query_cursor_row()
|
|
275
|
+
top = row - 1 # --place uses 0-indexed screen coordinates
|
|
276
|
+
|
|
277
|
+
subprocess.run(
|
|
278
|
+
[
|
|
279
|
+
"kitten", "icat",
|
|
280
|
+
f"--place={IMAGE_WIDTH_CELLS}x{image_height}@0x{top}",
|
|
281
|
+
"--align=left",
|
|
282
|
+
"--scale-up",
|
|
283
|
+
str(resolved),
|
|
284
|
+
]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
gap = 2
|
|
288
|
+
text_col = IMAGE_WIDTH_CELLS + gap
|
|
289
|
+
for i, line in enumerate(lines):
|
|
290
|
+
sys.stdout.write(f"\x1b[{top + i + 1};{text_col + 1}H{line}")
|
|
291
|
+
sys.stdout.flush()
|
|
292
|
+
|
|
293
|
+
final_row = top + max(image_height, len(lines)) + 1
|
|
294
|
+
sys.stdout.write(f"\x1b[{final_row};1H\n")
|
|
295
|
+
sys.stdout.flush()
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def render_once(config: dict | None = None):
|
|
300
|
+
if config is None:
|
|
301
|
+
config = load_config()
|
|
302
|
+
width = console.size.width
|
|
303
|
+
|
|
304
|
+
console.print(build_titlebar(config, width))
|
|
305
|
+
console.print()
|
|
306
|
+
|
|
307
|
+
ascii_text = load_ascii_art(config)
|
|
308
|
+
stats_table = build_stats_table(config)
|
|
309
|
+
|
|
310
|
+
if render_image_beside_stats(config, stats_table):
|
|
311
|
+
pass # image handled its own placement, including the stats table
|
|
312
|
+
elif ascii_text:
|
|
313
|
+
combined = Table.grid(padding=(0, 4))
|
|
314
|
+
combined.add_column()
|
|
315
|
+
combined.add_column()
|
|
316
|
+
combined.add_row(Text(ascii_text, style="bold"), stats_table)
|
|
317
|
+
console.print(combined)
|
|
318
|
+
else:
|
|
319
|
+
console.print(stats_table)
|
|
320
|
+
|
|
321
|
+
console.print()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# config menu
|
|
326
|
+
|
|
327
|
+
HELP_SELECT = "Arrows: move Z: select X: back"
|
|
328
|
+
HELP_CHECKBOX = "Arrows: move Z: toggle X: back/done"
|
|
329
|
+
HELP_TEXT = "Type to edit Enter: confirm Backspace: delete"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _addstr_safe(stdscr, y, x, text, attr=curses.A_NORMAL):
|
|
333
|
+
height, width = stdscr.getmaxyx()
|
|
334
|
+
if 0 <= y < height and x < width:
|
|
335
|
+
try:
|
|
336
|
+
stdscr.addstr(y, x, text[: max(width - x - 1, 0)], attr)
|
|
337
|
+
except curses.error:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _curses_menu_loop(stdscr, title: str, n: int, render_row, on_select, help_text: str):
|
|
342
|
+
"""Shared arrow-key menu loop used by curses_select and curses_checkbox.
|
|
343
|
+
render_row(i, selected) -> str for row i.
|
|
344
|
+
on_select(i) runs on Z; return True to exit the loop, False to keep it open.
|
|
345
|
+
Returns the selected index, or None if X was pressed."""
|
|
346
|
+
idx = 0
|
|
347
|
+
while True:
|
|
348
|
+
stdscr.clear()
|
|
349
|
+
_addstr_safe(stdscr, 0, 0, title, curses.A_BOLD)
|
|
350
|
+
for i in range(n):
|
|
351
|
+
attr = curses.A_REVERSE if i == idx else curses.A_NORMAL
|
|
352
|
+
_addstr_safe(stdscr, 2 + i, 0, render_row(i, i == idx), attr)
|
|
353
|
+
_addstr_safe(stdscr, 3 + n, 0, help_text, curses.A_DIM)
|
|
354
|
+
stdscr.refresh()
|
|
355
|
+
|
|
356
|
+
key = stdscr.getch()
|
|
357
|
+
if key == curses.KEY_UP:
|
|
358
|
+
idx = (idx - 1) % n
|
|
359
|
+
elif key == curses.KEY_DOWN:
|
|
360
|
+
idx = (idx + 1) % n
|
|
361
|
+
elif key in (ord("z"), ord("Z")):
|
|
362
|
+
if on_select(idx):
|
|
363
|
+
return idx
|
|
364
|
+
elif key in (ord("x"), ord("X")):
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def curses_select(stdscr, title: str, choices: list) -> str | None:
|
|
369
|
+
"""choices: list of (label, value)"""
|
|
370
|
+
|
|
371
|
+
def render_row(i, selected):
|
|
372
|
+
prefix = "> " if selected else " "
|
|
373
|
+
return f"{prefix}{choices[i][0]}"
|
|
374
|
+
|
|
375
|
+
idx = _curses_menu_loop(stdscr, title, len(choices), render_row, lambda i: True, HELP_SELECT)
|
|
376
|
+
return choices[idx][1] if idx is not None else None
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def curses_checkbox(stdscr, title: str, config: dict):
|
|
380
|
+
def render_row(i, selected):
|
|
381
|
+
key, label, _getter = STAT_DEFINITIONS[i]
|
|
382
|
+
checked = config["stats"].get(key, True)
|
|
383
|
+
box = "[X]" if checked else "[ ]"
|
|
384
|
+
prefix = "> " if selected else " "
|
|
385
|
+
return f"{prefix}{box} {label}"
|
|
386
|
+
|
|
387
|
+
def toggle(i):
|
|
388
|
+
stat_key = STAT_DEFINITIONS[i][0]
|
|
389
|
+
config["stats"][stat_key] = not config["stats"].get(stat_key, True)
|
|
390
|
+
return False # X exits this menu, not Z
|
|
391
|
+
|
|
392
|
+
_curses_menu_loop(stdscr, title, len(STAT_DEFINITIONS), render_row, toggle, HELP_CHECKBOX)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def curses_text_input(stdscr, title: str, default: str = "", footer: str | None = None) -> str:
|
|
396
|
+
curses.curs_set(1)
|
|
397
|
+
buf = list(default)
|
|
398
|
+
pos = len(buf)
|
|
399
|
+
try:
|
|
400
|
+
while True:
|
|
401
|
+
stdscr.clear()
|
|
402
|
+
_addstr_safe(stdscr, 0, 0, title, curses.A_BOLD)
|
|
403
|
+
_addstr_safe(stdscr, 2, 0, "".join(buf))
|
|
404
|
+
if footer:
|
|
405
|
+
_addstr_safe(stdscr, 4, 0, footer, curses.A_DIM)
|
|
406
|
+
_addstr_safe(stdscr, 5, 0, HELP_TEXT, curses.A_DIM)
|
|
407
|
+
else:
|
|
408
|
+
_addstr_safe(stdscr, 4, 0, HELP_TEXT, curses.A_DIM)
|
|
409
|
+
stdscr.move(2, min(pos, stdscr.getmaxyx()[1] - 1))
|
|
410
|
+
stdscr.refresh()
|
|
411
|
+
|
|
412
|
+
key = stdscr.getch()
|
|
413
|
+
if key in (curses.KEY_ENTER, 10, 13):
|
|
414
|
+
return "".join(buf)
|
|
415
|
+
elif key in (curses.KEY_BACKSPACE, 127, 8):
|
|
416
|
+
if pos > 0:
|
|
417
|
+
buf.pop(pos - 1)
|
|
418
|
+
pos -= 1
|
|
419
|
+
elif key == curses.KEY_LEFT:
|
|
420
|
+
pos = max(0, pos - 1)
|
|
421
|
+
elif key == curses.KEY_RIGHT:
|
|
422
|
+
pos = min(len(buf), pos + 1)
|
|
423
|
+
elif 32 <= key <= 126:
|
|
424
|
+
buf.insert(pos, chr(key))
|
|
425
|
+
pos += 1
|
|
426
|
+
finally:
|
|
427
|
+
curses.curs_set(0)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def curses_preview(stdscr, config: dict):
|
|
431
|
+
curses.def_prog_mode()
|
|
432
|
+
curses.endwin()
|
|
433
|
+
console.clear()
|
|
434
|
+
render_once(config)
|
|
435
|
+
console.print("[dim]Press Enter or Space to return...[/dim]")
|
|
436
|
+
|
|
437
|
+
with raw_terminal():
|
|
438
|
+
while True:
|
|
439
|
+
ch = sys.stdin.read(1)
|
|
440
|
+
if ch in ("\r", "\n", " "):
|
|
441
|
+
break
|
|
442
|
+
|
|
443
|
+
console.clear()
|
|
444
|
+
curses.reset_prog_mode()
|
|
445
|
+
stdscr.clear()
|
|
446
|
+
stdscr.refresh()
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _config_menu_titlebar(stdscr, config: dict):
|
|
450
|
+
placeholders_note = "Placeholders: {user} {host} {date} {time}"
|
|
451
|
+
config["titlebar"]["left"] = curses_text_input(
|
|
452
|
+
stdscr, "Left text:", config["titlebar"].get("left", ""), footer=placeholders_note
|
|
453
|
+
)
|
|
454
|
+
config["titlebar"]["center"] = curses_text_input(
|
|
455
|
+
stdscr, "Center text (blank for none):", config["titlebar"].get("center", ""), footer=placeholders_note
|
|
456
|
+
)
|
|
457
|
+
config["titlebar"]["right"] = curses_text_input(
|
|
458
|
+
stdscr, "Right text:", config["titlebar"].get("right", ""), footer=placeholders_note
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _config_menu_art(stdscr, config: dict):
|
|
463
|
+
mode = curses_select(
|
|
464
|
+
stdscr,
|
|
465
|
+
"Banner art:",
|
|
466
|
+
[("None", "none"), ("ASCII art (from a .txt file)", "ascii"), ("Image (kitty terminal only)", "image")],
|
|
467
|
+
)
|
|
468
|
+
if mode is None:
|
|
469
|
+
return
|
|
470
|
+
config["art"]["mode"] = mode
|
|
471
|
+
|
|
472
|
+
if mode == "ascii":
|
|
473
|
+
path = curses_text_input(
|
|
474
|
+
stdscr, "Path to .txt file with ASCII art:", config["art"].get("ascii_path", "")
|
|
475
|
+
)
|
|
476
|
+
config["art"]["ascii_path"] = path
|
|
477
|
+
elif mode == "image":
|
|
478
|
+
config["art"]["image_path"] = curses_text_input(
|
|
479
|
+
stdscr, "Path to image file:", config["art"].get("image_path", "")
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _run_config_menu(stdscr, config: dict):
|
|
484
|
+
curses.curs_set(0)
|
|
485
|
+
while True:
|
|
486
|
+
action = curses_select(
|
|
487
|
+
stdscr,
|
|
488
|
+
"nikofetch config",
|
|
489
|
+
[
|
|
490
|
+
("Toggle stats", "stats"),
|
|
491
|
+
("Edit titlebar text", "titlebar"),
|
|
492
|
+
("Set banner art (ascii / image / none)", "art"),
|
|
493
|
+
("Preview", "preview"),
|
|
494
|
+
("Save and exit", "save"),
|
|
495
|
+
("Exit without saving", "exit"),
|
|
496
|
+
],
|
|
497
|
+
)
|
|
498
|
+
if action is None or action == "exit":
|
|
499
|
+
return
|
|
500
|
+
elif action == "stats":
|
|
501
|
+
curses_checkbox(stdscr, "Toggle stats", config)
|
|
502
|
+
elif action == "titlebar":
|
|
503
|
+
_config_menu_titlebar(stdscr, config)
|
|
504
|
+
elif action == "art":
|
|
505
|
+
_config_menu_art(stdscr, config)
|
|
506
|
+
elif action == "preview":
|
|
507
|
+
curses_preview(stdscr, config)
|
|
508
|
+
elif action == "save":
|
|
509
|
+
save_config(config)
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def run_config_menu():
|
|
514
|
+
config = load_config()
|
|
515
|
+
curses.wrapper(_run_config_menu, config)
|
|
516
|
+
|
|
517
|
+
# Entry point
|
|
518
|
+
|
|
519
|
+
def main():
|
|
520
|
+
parser = argparse.ArgumentParser(description="nikofetch - minimal system info banner")
|
|
521
|
+
parser.add_argument("--config", action="store_true", help="open the interactive config menu")
|
|
522
|
+
args = parser.parse_args()
|
|
523
|
+
|
|
524
|
+
if args.config:
|
|
525
|
+
run_config_menu()
|
|
526
|
+
else:
|
|
527
|
+
render_once()
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
if __name__ == "__main__":
|
|
531
|
+
main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nikofetch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A tiny fastfetch-style system info banner
|
|
5
|
+
Author: niko
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/YOUR_USERNAME/nikofetch
|
|
8
|
+
Project-URL: Issues, https://github.com/YOUR_USERNAME/nikofetch/issues
|
|
9
|
+
Keywords: fetch,fastfetch,neofetch,system-info,cli,terminal
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Terminals
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: psutil>=5.9
|
|
21
|
+
Requires-Dist: rich>=13.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# nikofetch
|
|
25
|
+
|
|
26
|
+
A tiny fastfetch-style system info banner, written in Python.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
you@host Sat Jan 01, 12:00
|
|
30
|
+
|
|
31
|
+
OS: Linux 6.x
|
|
32
|
+
Kernel: 6.x
|
|
33
|
+
Shell: zsh
|
|
34
|
+
Terminal: kitty
|
|
35
|
+
Uptime: 2d 4h 12m
|
|
36
|
+
CPU: 12% @ 3.60 GHz
|
|
37
|
+
Memory: 8.2 GiB / 32.0 GiB (26%)
|
|
38
|
+
Disk: 120 GiB / 512 GiB (23%)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- Minimal, configurable stat banner (OS, kernel, shell, terminal, uptime, CPU, memory, disk)
|
|
44
|
+
- Customizable titlebar with `{user}`, `{host}`, `{date}`, `{time}` placeholders
|
|
45
|
+
- Optional ASCII art or (in [kitty](https://sw.kovidgoyal.net/kitty/)) inline image art
|
|
46
|
+
- Interactive terminal config menu — no need to hand-edit JSON
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pipx install nikofetch
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
or
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install nikofetch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
nikofetch # show the banner
|
|
64
|
+
nikofetch --config # open the interactive config menu
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Config is stored at `~/.config/nikofetch/config.json`.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone https://github.com/YOUR_USERNAME/nikofetch
|
|
73
|
+
cd nikofetch
|
|
74
|
+
pip install -e .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/nikofetch/__init__.py
|
|
5
|
+
src/nikofetch/cli.py
|
|
6
|
+
src/nikofetch.egg-info/PKG-INFO
|
|
7
|
+
src/nikofetch.egg-info/SOURCES.txt
|
|
8
|
+
src/nikofetch.egg-info/dependency_links.txt
|
|
9
|
+
src/nikofetch.egg-info/entry_points.txt
|
|
10
|
+
src/nikofetch.egg-info/requires.txt
|
|
11
|
+
src/nikofetch.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nikofetch
|