add somethin to myrummy and fix SJNfilemanager

This commit is contained in:
2026-08-29 20:56:45 +02:00
parent d78f25d530
commit 7786d20dd7
3 changed files with 274 additions and 42 deletions
+4 -2
View File
@@ -12,8 +12,10 @@ Beim Start werden Einstellungen abgefragt:
- Erstmeldung 30: Für die erste Meldung werden 30 Punkte gebraucht - Erstmeldung 30: Für die erste Meldung werden 30 Punkte gebraucht
Bots spielen automatisch: Sie legen, was sie können (Sätze und Folgen, Bots spielen automatisch: Sie legen, was sie können (Sätze und Folgen,
beachten die 30-Punkte-Regel) und ziehen sonst eine Karte. Die Handkarten beachten die 30-Punkte-Regel) und ziehen sonst eine Karte.
eines Bots werden nie angezeigt. Was ein Bot tut, bleibt geheim: Weder seine Handkarten noch die gezogene
Karte oder seine Melde-Aktionen werden angezeigt - man sieht nur, welche
Gruppen danach auf dem Tisch liegen.
Nach jedem beendeten Zug erscheint ein Übergabe-Bildschirm, damit der Nach jedem beendeten Zug erscheint ein Übergabe-Bildschirm, damit der
nächste Spieler die Karten des Vorgängers nicht sieht. nächste Spieler die Karten des Vorgängers nicht sieht.
+1 -8
View File
@@ -202,7 +202,6 @@ class Game:
def bot_turn(self): def bot_turn(self):
p=self.p p=self.p
print(f'\n {p.name} ist am Zug:')
melded=[] melded=[]
while True: while True:
m=self.find_meld(p.hand) m=self.find_meld(p.hand)
@@ -213,8 +212,6 @@ class Game:
if melded and (p.opened or not self.min30 or total>=30): if melded and (p.opened or not self.min30 or total>=30):
for m in melded: self.table.append(m) for m in melded: self.table.append(m)
if not p.opened: p.opened=True if not p.opened: p.opened=True
for m in melded:
print(' legt: '+' '.join(c.plain() for c in m))
if not p.hand: if not p.hand:
print(f'\n {p.name} gewinnt!') print(f'\n {p.name} gewinnt!')
return True return True
@@ -223,13 +220,9 @@ class Game:
for c in m: p.hand.append(c) for c in m: p.hand.append(c)
p.hand=sort_hand(p.hand) p.hand=sort_hand(p.hand)
if self.deck: if self.deck:
card=self.deck.pop(); p.hand.append(card); p.hand=sort_hand(p.hand) p.hand.append(self.deck.pop()); p.hand=sort_hand(p.hand)
print(f' kann nicht legen und zieht {card.plain()}.')
else:
print(' Stapel leer - Zug wird beendet.')
self.turn=(self.turn+1)%len(self.players); self.drew=False; self.melded=False; self.history=[] self.turn=(self.turn+1)%len(self.players); self.drew=False; self.melded=False; self.history=[]
self.handover=True self.handover=True
input('\n ENTER...')
return False return False
def run(self): def run(self):
+269 -32
View File
@@ -4,16 +4,27 @@
Keys: Keys:
Up/Down or k/j Navigate Up/Down or k/j Navigate
Enter Open (folder -> cd, file -> menu) Enter Open (folder -> cd, file -> menu)
/ Search (live-filter) / Search (live filter while typing)
Backspace or h Go to parent directory Backspace or h Go to parent directory
. Toggle hidden files . Toggle hidden files
r Rename selected entry
d Delete selected entry (asks for confirmation)
n Create a new folder
c / x Mark selected entry as copy / move source
p Paste the marked entry into the current folder
q Quit q Quit
Symlinks to folders and files are followed: folder links can be entered
like real directories. Broken symlinks are not listed.
""" """
import curses import curses
import locale
import os import os
import shlex import shlex
import shutil
import subprocess import subprocess
from collections.abc import Callable
from pathlib import Path from pathlib import Path
@@ -34,7 +45,10 @@ FILE_CATEGORIES = {
".mp3", ".wav", ".flac", ".ogg", ".m4a" ".mp3", ".wav", ".flac", ".ogg", ".m4a"
}, },
"Dokumente": { "Dokumente": {
".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf" ".doc", ".docx", ".odt", ".txt", ".rtf"
},
"PDF": {
".pdf"
}, },
"Tabellen": { "Tabellen": {
".xls", ".xlsx", ".ods", ".csv" ".xls", ".xlsx", ".ods", ".csv"
@@ -53,7 +67,7 @@ FILE_CATEGORIES = {
def get_category(filename): def get_category(filename: str) -> str:
extension = Path(filename).suffix.lower() extension = Path(filename).suffix.lower()
for category, extensions in FILE_CATEGORIES.items(): for category, extensions in FILE_CATEGORIES.items():
@@ -77,25 +91,31 @@ def human_size(n: int) -> str:
return f"{int(size)}T" return f"{int(size)}T"
def list_entries(cwd: Path, show_hidden: bool): def list_entries(cwd: Path, show_hidden: bool) -> tuple[list[Path], str | None]:
"""Return [.., dir1, dir2, ..., file1, file2, ...].""" """Return (entries, error); error is None on success.
Entries are [.., dir1, dir2, ..., file1, file2, ...]. Symlinks are
followed: links to folders are listed and can be entered like real
directories, links to files are listed as files. Broken symlinks are
not listed. On error, entries is just [..] and error carries a message.
"""
try: try:
all_entries = list(cwd.iterdir()) all_entries = list(cwd.iterdir())
except PermissionError: except OSError as e:
return [PARENT] return [PARENT], str(e) or type(e).__name__
if not show_hidden: if not show_hidden:
all_entries = [e for e in all_entries if not e.name.startswith(".")] all_entries = [e for e in all_entries if not e.name.startswith(".")]
dirs = sorted( dirs = sorted(
(e for e in all_entries if e.is_dir(follow_symlinks=False)), (e for e in all_entries if e.is_dir(follow_symlinks=True)),
key=lambda e: e.name.lower(), key=lambda e: e.name.lower(),
) )
files = sorted( files = sorted(
(e for e in all_entries if e.is_file(follow_symlinks=False)), (e for e in all_entries if e.is_file(follow_symlinks=True)),
key=lambda e: e.name.lower(), key=lambda e: e.name.lower(),
) )
return [PARENT] + dirs + files return [PARENT] + dirs + files, None
def is_parent(entry: Path) -> bool: def is_parent(entry: Path) -> bool:
@@ -116,9 +136,19 @@ def format_entry(name: str, is_dir: bool, full_path: Path, width: int) -> str:
# ---------- input ------------------------------------------------------------ # ---------- input ------------------------------------------------------------
def prompt_line(stdscr, prompt: str, initial: str = "") -> str | None: def prompt_line(
stdscr: curses.window,
prompt: str,
initial: str = "",
on_change: Callable[[str], None] | None = None,
) -> str | None:
"""Show a prompt at the bottom and read a line. Returns None if cancelled (ESC). """Show a prompt at the bottom and read a line. Returns None if cancelled (ESC).
Control characters (Tab, Ctrl+C, ...) never enter the buffer; printable
ASCII and Unicode characters (umlauts etc.) are accepted. If `on_change`
is given it is called with the current buffer after every edit, which
allows live-filtering while the user types.
Modes are restored in `finally` so a curses.error or any other exception Modes are restored in `finally` so a curses.error or any other exception
cannot leave the terminal in a half-configured state. cannot leave the terminal in a half-configured state.
""" """
@@ -136,19 +166,22 @@ def prompt_line(stdscr, prompt: str, initial: str = "") -> str | None:
buf = initial buf = initial
while True: while True:
ch = stdscr.getch() ch = stdscr.get_wch()
if ch == 27: # bare ESC (no following escape-sequence bytes) if ch in (27, -1): # bare ESC (get_wch reports -1 for ESC)
return None return None
if ch in (10, 13, curses.KEY_ENTER): if ch in (10, 13, curses.KEY_ENTER):
break break
if ch in (curses.KEY_BACKSPACE, 127, 8): if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"):
if buf: if buf:
buf = buf[:-1] buf = buf[:-1]
elif 0 <= ch < 256: elif isinstance(ch, str) and 32 <= ord(ch) != 127:
# Plain ASCII printable; safe to append. # Printable ASCII plus Unicode (umlauts, ...).
buf += chr(ch) # Control characters (< 0x20) and DEL are ignored.
# Arrow keys, function keys, etc. are silently ignored. buf += ch
# ints (arrow keys, function keys, ...) are silently ignored.
if on_change is not None:
on_change(buf)
stdscr.addstr(row, 0, prompt + (buf or "")) stdscr.addstr(row, 0, prompt + (buf or ""))
stdscr.clrtoeol() stdscr.clrtoeol()
stdscr.refresh() stdscr.refresh()
@@ -164,6 +197,14 @@ def prompt_line(stdscr, prompt: str, initial: str = "") -> str | None:
pass pass
def confirm(stdscr: curses.window, prompt: str) -> bool:
"""Ask a yes/no question via prompt_line. True only on explicit y/yes."""
answer = prompt_line(stdscr, prompt, initial="n")
if answer is None:
return False
return answer.strip().lower().startswith("y")
# ---------- rendering ------------------------------------------------------- # ---------- rendering -------------------------------------------------------
def init_colors() -> dict[str, int]: def init_colors() -> dict[str, int]:
@@ -194,7 +235,18 @@ def init_colors() -> dict[str, int]:
return pairs return pairs
def draw(stdscr, entries, filtered, selected, cwd, status, status_kind, show_hidden, query, colors): def draw(
stdscr: curses.window,
entries: list[Path],
filtered: list[Path] | None,
selected: int,
cwd: Path,
status: str,
status_kind: str,
show_hidden: bool,
query: str | None,
colors: dict[str, int],
) -> int:
stdscr.erase() stdscr.erase()
max_y, max_x = stdscr.getmaxyx() max_y, max_x = stdscr.getmaxyx()
@@ -242,7 +294,7 @@ def draw(stdscr, entries, filtered, selected, cwd, status, status_kind, show_hid
full = cwd.parent full = cwd.parent
else: else:
name = entry.name name = entry.name
is_dir = entry.is_dir(follow_symlinks=False) is_dir = entry.is_dir(follow_symlinks=True)
full = entry full = entry
label = format_entry(name, is_dir, full, max_x - 1) label = format_entry(name, is_dir, full, max_x - 1)
@@ -287,7 +339,7 @@ def draw(stdscr, entries, filtered, selected, cwd, status, status_kind, show_hid
pass pass
# Footer (key hints) # Footer (key hints)
footer = " Up/Dn move | Enter open | / search | .. parent | . hidden | q quit " footer = " Up/Dn move | Enter open | / live-search | r rename | d delete | n new dir | c copy | x move | p paste | . hidden | q quit "
try: try:
stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM) stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM)
except curses.error: except curses.error:
@@ -342,7 +394,9 @@ OPEN_COMMANDS = {
} }
def file_menu(stdscr, filepath: Path, colors) -> list[str] | None: def file_menu(
stdscr: curses.window, filepath: Path, colors: dict[str, int]
) -> list[str] | None:
"""Show the open-with menu based on the file category. """Show the open-with menu based on the file category.
Returns argv list, or None if cancelled. Returns argv list, or None if cancelled.
""" """
@@ -447,7 +501,7 @@ def file_menu(stdscr, filepath: Path, colors) -> list[str] | None:
def run_external(stdscr, argv: list[str]) -> str: def run_external(stdscr: curses.window, argv: list[str]) -> str:
"""Suspend curses, run argv, then restore. Returns a status string. """Suspend curses, run argv, then restore. Returns a status string.
Defensive: we snapshot cwd before, run, then forcibly chdir back to the Defensive: we snapshot cwd before, run, then forcibly chdir back to the
@@ -489,7 +543,7 @@ def run_external(stdscr, argv: list[str]) -> str:
# ---------- main loop ------------------------------------------------------- # ---------- main loop -------------------------------------------------------
def main(stdscr): def main(stdscr: curses.window) -> None:
colors = init_colors() colors = init_colors()
curses.curs_set(0) curses.curs_set(0)
stdscr.keypad(True) stdscr.keypad(True)
@@ -502,10 +556,15 @@ def main(stdscr):
status_kind = "status_ok" status_kind = "status_ok"
query = None query = None
filtered = None # when not None, used for display (search active) filtered = None # when not None, used for display (search active)
copy_source: Path | None = None
move_source: Path | None = None
while True: while True:
try: try:
entries = list_entries(cwd, show_hidden) entries, list_error = list_entries(cwd, show_hidden)
if list_error is not None:
status = f"Cannot read directory: {list_error}"
status_kind = "status_err"
if filtered is not None: if filtered is not None:
# Re-apply filter against the fresh entries # Re-apply filter against the fresh entries
@@ -578,7 +637,7 @@ def main(stdscr):
selected = 0 selected = 0
filtered = None filtered = None
query = None query = None
elif entry.is_dir(follow_symlinks=False): elif entry.is_dir(follow_symlinks=True):
try: try:
os.chdir(entry) os.chdir(entry)
cwd = Path.cwd() cwd = Path.cwd()
@@ -592,16 +651,40 @@ def main(stdscr):
status = f"Cannot enter {entry.name}: {e}" status = f"Cannot enter {entry.name}: {e}"
status_kind = "status_err" status_kind = "status_err"
else: else:
if entry.is_file(follow_symlinks=False): if entry.is_file(follow_symlinks=True):
argv = file_menu(stdscr, entry, colors) argv = file_menu(stdscr, entry, colors)
if argv is not None: if argv is not None:
status = run_external(stdscr, argv) status = run_external(stdscr, argv)
status_kind = "status_ok" if status.startswith("Ran:") else "status_err" status_kind = "status_ok" if status.startswith("Ran:") else "status_err"
cwd = Path.cwd() new_cwd = Path.cwd()
selected = 0 if new_cwd != cwd:
cwd = new_cwd
selected = 0
elif ch == ord("/"): elif ch == ord("/"):
q = prompt_line(stdscr, "/ ") def apply_search(buf: str) -> None:
"""Live-render the list while the user types."""
nonlocal query, filtered, selected
query = buf
if buf == "":
filtered = None
else:
qlow = buf.lower()
candidates = [e for e in entries if qlow in e.name.lower()]
if not candidates:
filtered = []
else:
if PARENT in entries:
candidates = [PARENT] + [
e for e in candidates if e != PARENT
]
filtered = candidates
selected = draw(
stdscr, entries, filtered, selected, cwd,
"", "status_ok", show_hidden, query, colors,
)
q = prompt_line(stdscr, "/ ", on_change=apply_search)
if q is None: if q is None:
# cancel search # cancel search
filtered = None filtered = None
@@ -625,17 +708,171 @@ def main(stdscr):
selected = 0 selected = 0
# Re-apply search if active # Re-apply search if active
if query: if query:
fresh, _err = list_entries(cwd, show_hidden)
qlow = query.lower() qlow = query.lower()
candidates = [ candidates = [
e for e in list_entries(cwd, show_hidden) if qlow in e.name.lower() e for e in fresh if qlow in e.name.lower()
] ]
if PARENT in fresh:
candidates = [PARENT] + [
e for e in candidates if e != PARENT
]
filtered = candidates if candidates else [] filtered = candidates if candidates else []
elif ch in (ord("r"), ord("R")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot rename '..'"
status_kind = "status_err"
continue
new_name = prompt_line(
stdscr, f"Rename '{entry.name}' to: ", initial=entry.name
)
if new_name is None:
continue
new_name = new_name.strip()
if not new_name or new_name == entry.name:
continue
try:
target = entry.parent / new_name
if target.exists():
raise FileExistsError(f"'{new_name}' already exists")
entry.rename(target)
status = f"Renamed to '{new_name}'"
status_kind = "status_ok"
except OSError as e:
status = f"Rename failed: {e}"
status_kind = "status_err"
elif ch in (ord("d"), ord("D")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot delete '..'"
status_kind = "status_err"
continue
if not confirm(stdscr, f"Delete '{entry.name}'? (y/N) "):
status = "Delete cancelled"
status_kind = "status_ok"
continue
try:
if entry.is_symlink():
entry.unlink()
elif entry.is_dir():
shutil.rmtree(entry)
else:
entry.unlink()
status = f"Deleted '{entry.name}'"
status_kind = "status_ok"
except OSError as e:
status = f"Delete failed: {e}"
status_kind = "status_err"
elif ch in (ord("n"), ord("N")):
name = prompt_line(stdscr, "New folder name: ")
if name is None:
continue
name = name.strip()
if not name:
continue
try:
(cwd / name).mkdir()
status = f"Created '{name}/'"
status_kind = "status_ok"
except OSError as e:
status = f"Create failed: {e}"
status_kind = "status_err"
elif ch in (ord("c"), ord("C")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot copy '..'"
status_kind = "status_err"
continue
copy_source = entry
move_source = None
status = f"Copy: '{entry.name}' (press p to paste)"
status_kind = "status_ok"
elif ch in (ord("x"), ord("X")):
vis = filtered if filtered is not None else entries
if not vis:
continue
entry = vis[selected]
if is_parent(entry):
status = "Cannot move '..'"
status_kind = "status_err"
continue
move_source = entry
copy_source = None
status = f"Move: '{entry.name}' (press p to paste)"
status_kind = "status_ok"
elif ch in (ord("p"), ord("P")):
src = copy_source if copy_source is not None else move_source
if src is None:
status = "Nothing to paste (mark with c or x first)"
status_kind = "status_err"
continue
moving = move_source is not None
dest = cwd / src.name
if src == dest:
status = "Source and destination are the same"
status_kind = "status_err"
copy_source = None
move_source = None
continue
try:
if dest.exists():
if not confirm(stdscr, f"Overwrite '{dest.name}'? (y/N) "):
status = "Paste cancelled"
status_kind = "status_ok"
continue
if dest.is_dir() and not src.is_dir():
raise IsADirectoryError(
f"'{dest.name}' is a directory"
)
if dest.is_file() and src.is_dir():
raise NotADirectoryError(
f"'{dest.name}' is a file"
)
if moving:
if dest.exists() and dest.is_dir() and src.is_dir():
# Replace the target folder (user confirmed).
shutil.rmtree(dest)
shutil.move(src, dest)
else:
if src.is_dir():
shutil.copytree(src, dest, dirs_exist_ok=True)
else:
shutil.copy2(src, dest)
status = f"{'Moved' if moving else 'Copied'} '{src.name}'"
status_kind = "status_ok"
except OSError as e:
status = f"{'Move' if moving else 'Copy'} failed: {e}"
status_kind = "status_err"
finally:
copy_source = None
move_source = None
except KeyboardInterrupt: except KeyboardInterrupt:
break break
def entrypoint(): def entrypoint() -> None:
# Make sure multi-byte input (umlauts, ...) is decoded correctly.
try:
locale.setlocale(locale.LC_ALL, "")
except locale.Error:
pass
try: try:
curses.wrapper(main) curses.wrapper(main)
except KeyboardInterrupt: except KeyboardInterrupt: