initialise project
This commit is contained in:
@@ -0,0 +1,646 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SJN File Manager - a TUI file manager.
|
||||
|
||||
Keys:
|
||||
Up/Down or k/j Navigate
|
||||
Enter Open (folder -> cd, file -> menu)
|
||||
/ Search (live-filter)
|
||||
Backspace or h Go to parent directory
|
||||
. Toggle hidden files
|
||||
q Quit
|
||||
"""
|
||||
|
||||
import curses
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PARENT = Path("..")
|
||||
|
||||
|
||||
# ---------- helpers ---------------------------------------------------------
|
||||
|
||||
|
||||
FILE_CATEGORIES = {
|
||||
"Bilder": {
|
||||
".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"
|
||||
},
|
||||
"Videos": {
|
||||
".mp4", ".mkv", ".avi", ".mov", ".webm"
|
||||
},
|
||||
"Musik": {
|
||||
".mp3", ".wav", ".flac", ".ogg", ".m4a"
|
||||
},
|
||||
"Dokumente": {
|
||||
".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf"
|
||||
},
|
||||
"Tabellen": {
|
||||
".xls", ".xlsx", ".ods", ".csv"
|
||||
},
|
||||
"Präsentationen": {
|
||||
".ppt", ".pptx", ".odp"
|
||||
},
|
||||
"Code": {
|
||||
".py", ".js", ".ts", ".java", ".c", ".cpp", ".h",
|
||||
".rs", ".go", ".php", ".sh"
|
||||
},
|
||||
"Archive": {
|
||||
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
def get_category(filename):
|
||||
extension = Path(filename).suffix.lower()
|
||||
|
||||
for category, extensions in FILE_CATEGORIES.items():
|
||||
if extension in extensions:
|
||||
return category
|
||||
|
||||
return "Sonstige"
|
||||
|
||||
|
||||
|
||||
def human_size(n: int) -> str:
|
||||
"""Return a human-readable file size."""
|
||||
units = ["B", "K", "M", "G", "T"]
|
||||
size = float(n)
|
||||
for unit in units:
|
||||
if size < 1024 or unit == units[-1]:
|
||||
if unit == "B":
|
||||
return f"{int(size)}{unit}"
|
||||
return f"{size:.1f}{unit}"
|
||||
size /= 1024
|
||||
return f"{int(size)}T"
|
||||
|
||||
|
||||
def list_entries(cwd: Path, show_hidden: bool):
|
||||
"""Return [.., dir1, dir2, ..., file1, file2, ...]."""
|
||||
try:
|
||||
all_entries = list(cwd.iterdir())
|
||||
except PermissionError:
|
||||
return [PARENT]
|
||||
|
||||
if not show_hidden:
|
||||
all_entries = [e for e in all_entries if not e.name.startswith(".")]
|
||||
|
||||
dirs = sorted(
|
||||
(e for e in all_entries if e.is_dir(follow_symlinks=False)),
|
||||
key=lambda e: e.name.lower(),
|
||||
)
|
||||
files = sorted(
|
||||
(e for e in all_entries if e.is_file(follow_symlinks=False)),
|
||||
key=lambda e: e.name.lower(),
|
||||
)
|
||||
return [PARENT] + dirs + files
|
||||
|
||||
|
||||
def is_parent(entry: Path) -> bool:
|
||||
return entry == PARENT
|
||||
|
||||
|
||||
def format_entry(name: str, is_dir: bool, full_path: Path, width: int) -> str:
|
||||
"""Format an entry for display, padded to width."""
|
||||
if is_dir:
|
||||
label = f"[D] {name}/"
|
||||
else:
|
||||
try:
|
||||
label = f"[F] {name} ({human_size(full_path.stat().st_size)})"
|
||||
except OSError:
|
||||
label = f"[F] {name}"
|
||||
return label[:width].ljust(width)
|
||||
|
||||
|
||||
# ---------- input ------------------------------------------------------------
|
||||
|
||||
def prompt_line(stdscr, prompt: str, initial: str = "") -> str | None:
|
||||
"""Show a prompt at the bottom and read a line. Returns None if cancelled (ESC).
|
||||
|
||||
Modes are restored in `finally` so a curses.error or any other exception
|
||||
cannot leave the terminal in a half-configured state.
|
||||
"""
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
row = max_y - 2
|
||||
|
||||
curses.curs_set(1)
|
||||
curses.echo()
|
||||
# Keep keypad ON so arrow keys arrive as KEY_* constants instead of an
|
||||
# ESC prefix that would otherwise be mis-read as "user pressed cancel".
|
||||
try:
|
||||
stdscr.addstr(row, 0, prompt)
|
||||
stdscr.clrtoeol()
|
||||
stdscr.refresh()
|
||||
|
||||
buf = initial
|
||||
while True:
|
||||
ch = stdscr.getch()
|
||||
if ch == 27: # bare ESC (no following escape-sequence bytes)
|
||||
return None
|
||||
if ch in (10, 13, curses.KEY_ENTER):
|
||||
break
|
||||
if ch in (curses.KEY_BACKSPACE, 127, 8):
|
||||
if buf:
|
||||
buf = buf[:-1]
|
||||
elif 0 <= ch < 256:
|
||||
# Plain ASCII printable; safe to append.
|
||||
buf += chr(ch)
|
||||
# Arrow keys, function keys, etc. are silently ignored.
|
||||
|
||||
stdscr.addstr(row, 0, prompt + (buf or ""))
|
||||
stdscr.clrtoeol()
|
||||
stdscr.refresh()
|
||||
|
||||
return buf
|
||||
finally:
|
||||
curses.noecho()
|
||||
curses.curs_set(0)
|
||||
try:
|
||||
stdscr.touchwin()
|
||||
stdscr.refresh()
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- rendering -------------------------------------------------------
|
||||
|
||||
def init_colors() -> dict[str, int]:
|
||||
"""Initialize color pairs and return a mapping of name -> pair id."""
|
||||
pairs = {
|
||||
"dir": 1,
|
||||
"file": 2,
|
||||
"selected": 3,
|
||||
"selected_dir": 4,
|
||||
"status_ok": 5,
|
||||
"status_err": 6,
|
||||
"header": 7,
|
||||
}
|
||||
if not curses.has_colors():
|
||||
return {}
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
try:
|
||||
curses.init_pair(1, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(2, curses.COLOR_WHITE, -1)
|
||||
curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE)
|
||||
curses.init_pair(4, curses.COLOR_CYAN, curses.COLOR_WHITE)
|
||||
curses.init_pair(5, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(6, curses.COLOR_RED, -1)
|
||||
curses.init_pair(7, curses.COLOR_YELLOW, -1)
|
||||
except curses.error:
|
||||
pass
|
||||
return pairs
|
||||
|
||||
|
||||
def draw(stdscr, entries, filtered, selected, cwd, status, status_kind, show_hidden, query, colors):
|
||||
stdscr.erase()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
visible = filtered if filtered is not None else entries
|
||||
|
||||
# Header
|
||||
hidden_flag = " (hidden shown)" if show_hidden else ""
|
||||
header = f" {cwd}{hidden_flag} "
|
||||
if colors:
|
||||
stdscr.attron(curses.color_pair(colors["header"]) | curses.A_BOLD)
|
||||
try:
|
||||
stdscr.addstr(0, 0, header[: max_x - 1])
|
||||
except curses.error:
|
||||
pass
|
||||
if colors:
|
||||
stdscr.attroff(curses.color_pair(colors["header"]) | curses.A_BOLD)
|
||||
|
||||
# List area
|
||||
list_top = 1
|
||||
list_bottom = max_y - 2 # leave one row for status, one for footer
|
||||
rows = max(0, list_bottom - list_top)
|
||||
|
||||
# Clamp selection to a valid index in `visible`.
|
||||
if visible:
|
||||
selected = max(0, min(selected, len(visible) - 1))
|
||||
else:
|
||||
selected = 0
|
||||
|
||||
if rows <= 0:
|
||||
# Terminal too small to render anything meaningful.
|
||||
stdscr.refresh()
|
||||
return selected
|
||||
|
||||
# Scroll window
|
||||
start = 0
|
||||
if selected >= rows:
|
||||
start = selected - rows + 1
|
||||
|
||||
end = min(start + rows, len(visible))
|
||||
for entry_i in range(start, end):
|
||||
entry = visible[entry_i]
|
||||
if is_parent(entry):
|
||||
name = ".."
|
||||
is_dir = True
|
||||
full = cwd.parent
|
||||
else:
|
||||
name = entry.name
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
full = entry
|
||||
|
||||
label = format_entry(name, is_dir, full, max_x - 1)
|
||||
|
||||
attr = curses.A_NORMAL
|
||||
if colors:
|
||||
if entry_i == selected:
|
||||
attr = curses.color_pair(
|
||||
colors["selected_dir"] if is_dir else colors["selected"]
|
||||
) | curses.A_BOLD
|
||||
else:
|
||||
attr = curses.color_pair(
|
||||
colors["dir"] if is_dir else colors["file"]
|
||||
)
|
||||
else:
|
||||
if entry_i == selected:
|
||||
attr = curses.A_REVERSE
|
||||
elif is_dir:
|
||||
attr = curses.A_BOLD
|
||||
|
||||
line_y = list_top + (entry_i - start)
|
||||
if line_y < list_bottom:
|
||||
try:
|
||||
stdscr.addnstr(line_y, 0, label, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Status line (one row above footer)
|
||||
status_y = max_y - 2
|
||||
status_text = ""
|
||||
status_attr = curses.A_DIM
|
||||
if status:
|
||||
status_text = status
|
||||
elif query is not None:
|
||||
status_text = f"/ {query}"
|
||||
if status_text and colors and status_kind in colors:
|
||||
status_attr = curses.color_pair(colors[status_kind]) | curses.A_BOLD
|
||||
try:
|
||||
if status_text:
|
||||
stdscr.addnstr(status_y, 0, status_text, max_x - 1, status_attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Footer (key hints)
|
||||
footer = " Up/Dn move | Enter open | / search | .. parent | . hidden | q quit "
|
||||
try:
|
||||
stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
return selected
|
||||
|
||||
|
||||
# ---------- file type / open-with configuration -----------------------------
|
||||
|
||||
OPEN_COMMANDS = {
|
||||
"Bilder": [
|
||||
("feh", ["feh", "{file}"]),
|
||||
("imv", ["imv", "{file}"]),
|
||||
],
|
||||
|
||||
"Videos": [
|
||||
("mpv", ["mpv", "{file}"]),
|
||||
("vlc", ["vlc", "{file}"]),
|
||||
("show", ["show", "{file}"]),
|
||||
("newshow", ["newshow", "{file}"]),
|
||||
],
|
||||
|
||||
"Musik": [
|
||||
("mpv", ["mpv", "{file}"]),
|
||||
("ffplay", ["ffplay", "{file}"]),
|
||||
],
|
||||
|
||||
"Dokumente": [
|
||||
("LibreOffice", ["libreoffice", "{file}"]),
|
||||
("firefox", ["firefox", "{file}"]),
|
||||
],
|
||||
|
||||
"PDF": [
|
||||
("zathura", ["zathura", "{file}"]),
|
||||
("firefox", ["firefox", "{file}"]),
|
||||
],
|
||||
|
||||
"Code": [
|
||||
("nano", ["nano", "{file}"]),
|
||||
("helix", ["helix", "{file}"]),
|
||||
],
|
||||
|
||||
"Archive": [
|
||||
("File Roller", ["file-roller", "{file}"]),
|
||||
],
|
||||
|
||||
"Sonstige": [
|
||||
("nano", ["nano", "{file}"]),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def file_menu(stdscr, filepath: Path, colors) -> list[str] | None:
|
||||
"""Show the open-with menu based on the file category.
|
||||
Returns argv list, or None if cancelled.
|
||||
"""
|
||||
|
||||
category = get_category(filepath)
|
||||
|
||||
# Get commands for this category.
|
||||
# If the category does not exist, fall back to "Sonstige".
|
||||
commands = OPEN_COMMANDS.get(
|
||||
category,
|
||||
OPEN_COMMANDS.get("Sonstige", [])
|
||||
)
|
||||
|
||||
stdscr.erase()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
lines = [
|
||||
f"Open: {filepath.name}",
|
||||
f"Type: {category}",
|
||||
"",
|
||||
]
|
||||
|
||||
# Add category-specific programs.
|
||||
for number, (name, _argv) in enumerate(commands, start=1):
|
||||
lines.append(f" {number}) {name}")
|
||||
|
||||
# Custom command is always available.
|
||||
custom_number = len(commands) + 1
|
||||
|
||||
lines.extend([
|
||||
f" {custom_number}) custom command",
|
||||
"",
|
||||
f"Press 1-{custom_number} - ESC to cancel",
|
||||
])
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if i >= max_y - 1:
|
||||
break
|
||||
|
||||
try:
|
||||
if colors:
|
||||
stdscr.addnstr(
|
||||
i,
|
||||
2,
|
||||
line,
|
||||
max_x - 3,
|
||||
curses.color_pair(colors["header"]) | curses.A_BOLD,
|
||||
)
|
||||
else:
|
||||
stdscr.addnstr(
|
||||
i,
|
||||
2,
|
||||
line,
|
||||
max_x - 3,
|
||||
curses.A_BOLD,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
while True:
|
||||
ch = stdscr.getch()
|
||||
|
||||
if ch == 27: # ESC
|
||||
return None
|
||||
|
||||
if ord("1") <= ch <= ord("9"):
|
||||
number = ch - ord("0")
|
||||
|
||||
# Predefined command
|
||||
if 1 <= number <= len(commands):
|
||||
_name, argv = commands[number - 1]
|
||||
|
||||
return [
|
||||
arg.replace("{file}", str(filepath))
|
||||
for arg in argv
|
||||
]
|
||||
|
||||
# Custom command
|
||||
if number == custom_number:
|
||||
cmd = prompt_line(
|
||||
stdscr,
|
||||
"Command (use {{file}} for path): "
|
||||
)
|
||||
|
||||
if cmd is None or not cmd.strip():
|
||||
return None
|
||||
|
||||
if "{file}" in cmd:
|
||||
resolved = cmd.replace(
|
||||
"{file}",
|
||||
str(filepath)
|
||||
)
|
||||
else:
|
||||
resolved = cmd + " " + str(filepath)
|
||||
|
||||
try:
|
||||
return shlex.split(resolved)
|
||||
except ValueError:
|
||||
return resolved.split()
|
||||
|
||||
|
||||
|
||||
def run_external(stdscr, argv: list[str]) -> str:
|
||||
"""Suspend curses, run argv, then restore. Returns a status string.
|
||||
|
||||
Defensive: we snapshot cwd before, run, then forcibly chdir back to the
|
||||
snapshot afterwards. Even though `subprocess.run` with a list argv does
|
||||
not invoke a shell and therefore cannot change the parent's cwd, if a
|
||||
future change ever switches to `shell=True` this guarantees we stay put.
|
||||
"""
|
||||
saved_cwd = os.getcwd()
|
||||
curses.def_prog_mode()
|
||||
curses.endwin()
|
||||
status = ""
|
||||
try:
|
||||
try:
|
||||
result = subprocess.run(argv)
|
||||
if result.returncode == 0:
|
||||
status = f"Ran: {' '.join(argv)}"
|
||||
else:
|
||||
status = f"Exit {result.returncode}: {' '.join(argv)}"
|
||||
except FileNotFoundError:
|
||||
status = f"Command not found: {argv[0]}"
|
||||
except KeyboardInterrupt:
|
||||
status = "Interrupted"
|
||||
except Exception as e: # noqa: BLE001
|
||||
status = f"Error: {e}"
|
||||
finally:
|
||||
# Always restore cwd before resuming curses.
|
||||
try:
|
||||
os.chdir(saved_cwd)
|
||||
except OSError:
|
||||
pass
|
||||
curses.reset_prog_mode()
|
||||
try:
|
||||
stdscr.redrawwin()
|
||||
stdscr.refresh()
|
||||
except curses.error:
|
||||
pass
|
||||
return status
|
||||
|
||||
|
||||
# ---------- main loop -------------------------------------------------------
|
||||
|
||||
def main(stdscr):
|
||||
colors = init_colors()
|
||||
curses.curs_set(0)
|
||||
stdscr.keypad(True)
|
||||
|
||||
cwd = Path.cwd()
|
||||
show_hidden = False
|
||||
|
||||
selected = 0
|
||||
status = ""
|
||||
status_kind = "status_ok"
|
||||
query = None
|
||||
filtered = None # when not None, used for display (search active)
|
||||
|
||||
while True:
|
||||
try:
|
||||
entries = list_entries(cwd, show_hidden)
|
||||
|
||||
if filtered is not None:
|
||||
# Re-apply filter against the fresh entries
|
||||
if query is None:
|
||||
filtered = None
|
||||
else:
|
||||
qlow = query.lower()
|
||||
candidates = [e for e in entries if qlow in e.name.lower()]
|
||||
if not candidates:
|
||||
filtered = []
|
||||
else:
|
||||
if candidates[0] != PARENT and PARENT in entries:
|
||||
candidates = [PARENT] + [
|
||||
e for e in candidates if e != PARENT
|
||||
]
|
||||
filtered = candidates
|
||||
|
||||
selected = draw(
|
||||
stdscr,
|
||||
entries,
|
||||
filtered,
|
||||
selected,
|
||||
cwd,
|
||||
status,
|
||||
status_kind,
|
||||
show_hidden,
|
||||
query,
|
||||
colors,
|
||||
)
|
||||
|
||||
status = ""
|
||||
status_kind = "status_ok"
|
||||
|
||||
ch = stdscr.getch()
|
||||
|
||||
if ch in (ord("q"),):
|
||||
break
|
||||
|
||||
if ch in (curses.KEY_UP, ord("k")):
|
||||
vis = filtered if filtered is not None else entries
|
||||
if vis:
|
||||
selected = (selected - 1) % len(vis)
|
||||
|
||||
elif ch in (curses.KEY_DOWN, ord("j")):
|
||||
vis = filtered if filtered is not None else entries
|
||||
if vis:
|
||||
selected = (selected + 1) % len(vis)
|
||||
|
||||
elif ch in (curses.KEY_BACKSPACE, 127, 8, ord("h")):
|
||||
# Parent directory
|
||||
if cwd != cwd.parent:
|
||||
cwd = cwd.parent
|
||||
os.chdir(cwd)
|
||||
selected = 0
|
||||
filtered = None
|
||||
query = None
|
||||
else:
|
||||
status = "Already at filesystem root"
|
||||
status_kind = "status_err"
|
||||
|
||||
elif ch in (curses.KEY_ENTER, 10, 13):
|
||||
vis = filtered if filtered is not None else entries
|
||||
if not vis:
|
||||
continue
|
||||
entry = vis[selected]
|
||||
if is_parent(entry):
|
||||
if cwd != cwd.parent:
|
||||
cwd = cwd.parent
|
||||
os.chdir(cwd)
|
||||
selected = 0
|
||||
filtered = None
|
||||
query = None
|
||||
elif entry.is_dir(follow_symlinks=False):
|
||||
try:
|
||||
os.chdir(entry)
|
||||
cwd = Path.cwd()
|
||||
selected = 0
|
||||
filtered = None
|
||||
query = None
|
||||
except PermissionError:
|
||||
status = f"Permission denied: {entry.name}"
|
||||
status_kind = "status_err"
|
||||
except OSError as e:
|
||||
status = f"Cannot enter {entry.name}: {e}"
|
||||
status_kind = "status_err"
|
||||
else:
|
||||
if entry.is_file(follow_symlinks=False):
|
||||
argv = file_menu(stdscr, entry, colors)
|
||||
if argv is not None:
|
||||
status = run_external(stdscr, argv)
|
||||
status_kind = "status_ok" if status.startswith("Ran:") else "status_err"
|
||||
cwd = Path.cwd()
|
||||
selected = 0
|
||||
|
||||
elif ch == ord("/"):
|
||||
q = prompt_line(stdscr, "/ ")
|
||||
if q is None:
|
||||
# cancel search
|
||||
filtered = None
|
||||
query = None
|
||||
selected = 0
|
||||
else:
|
||||
query = q
|
||||
if q == "":
|
||||
filtered = None
|
||||
else:
|
||||
qlow = q.lower()
|
||||
candidates = [e for e in entries if qlow in e.name.lower()]
|
||||
if PARENT in entries:
|
||||
candidates = [PARENT] + [
|
||||
e for e in candidates if e != PARENT
|
||||
]
|
||||
filtered = candidates if candidates else []
|
||||
|
||||
elif ch == ord("."):
|
||||
show_hidden = not show_hidden
|
||||
selected = 0
|
||||
# Re-apply search if active
|
||||
if query:
|
||||
qlow = query.lower()
|
||||
candidates = [
|
||||
e for e in list_entries(cwd, show_hidden) if qlow in e.name.lower()
|
||||
]
|
||||
filtered = candidates if candidates else []
|
||||
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
|
||||
def entrypoint():
|
||||
try:
|
||||
curses.wrapper(main)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
entrypoint()
|
||||
@@ -0,0 +1,22 @@
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ziel = datetime(2038, 1, 19, 3, 14, 7, tzinfo=timezone.utc)
|
||||
|
||||
while True:
|
||||
jetzt = datetime.now(timezone.utc)
|
||||
rest = ziel - jetzt
|
||||
|
||||
tage = rest.days
|
||||
stunden = rest.seconds // 3600
|
||||
minuten = (rest.seconds % 3600) // 60
|
||||
sekunden = rest.seconds % 60
|
||||
|
||||
print(
|
||||
f"\rY2038 Countdown: {tage} Tage "
|
||||
f"{stunden:02d}:{minuten:02d}:{sekunden:02d}",
|
||||
end="",
|
||||
flush=True
|
||||
)
|
||||
|
||||
time.sleep(1)
|
||||
@@ -0,0 +1,22 @@
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
ziel = datetime(2038, 1, 19, 3, 14, 7, tzinfo=timezone.utc)
|
||||
|
||||
while True:
|
||||
jetzt = datetime.now(timezone.utc)
|
||||
rest = ziel - jetzt
|
||||
|
||||
tage = rest.days
|
||||
stunden = rest.seconds // 3600
|
||||
minuten = (rest.seconds % 3600) // 60
|
||||
sekunden = rest.seconds % 60
|
||||
|
||||
print(
|
||||
f"\rY2038 Countdown: {tage} Tage "
|
||||
f"{stunden:02d}:{minuten:02d}:{sekunden:02d}",
|
||||
end="",
|
||||
flush=True
|
||||
)
|
||||
|
||||
time.sleep(1)
|
||||
Reference in New Issue
Block a user