diff --git a/myrummy/READEME.txt b/myrummy/READEME.txt index 4e82db1..706e327 100644 --- a/myrummy/READEME.txt +++ b/myrummy/READEME.txt @@ -12,8 +12,10 @@ Beim Start werden Einstellungen abgefragt: - Erstmeldung 30: Für die erste Meldung werden 30 Punkte gebraucht 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 -eines Bots werden nie angezeigt. +beachten die 30-Punkte-Regel) und ziehen sonst eine Karte. +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 nächste Spieler die Karten des Vorgängers nicht sieht. diff --git a/myrummy/game.py b/myrummy/game.py index 0b70e1f..d07abc1 100644 --- a/myrummy/game.py +++ b/myrummy/game.py @@ -202,7 +202,6 @@ class Game: def bot_turn(self): p=self.p - print(f'\n {p.name} ist am Zug:') melded=[] while True: m=self.find_meld(p.hand) @@ -213,8 +212,6 @@ class Game: if melded and (p.opened or not self.min30 or total>=30): for m in melded: self.table.append(m) if not p.opened: p.opened=True - for m in melded: - print(' legt: '+' '.join(c.plain() for c in m)) if not p.hand: print(f'\n {p.name} gewinnt!') return True @@ -223,13 +220,9 @@ class Game: for c in m: p.hand.append(c) p.hand=sort_hand(p.hand) if self.deck: - card=self.deck.pop(); p.hand.append(card); p.hand=sort_hand(p.hand) - print(f' kann nicht legen und zieht {card.plain()}.') - else: - print(' Stapel leer - Zug wird beendet.') + p.hand.append(self.deck.pop()); p.hand=sort_hand(p.hand) self.turn=(self.turn+1)%len(self.players); self.drew=False; self.melded=False; self.history=[] self.handover=True - input('\n ENTER...') return False def run(self): diff --git a/prg/SJNet_filemanager.py b/prg/SJNet_filemanager.py index 0467fea..a8d26c2 100644 --- a/prg/SJNet_filemanager.py +++ b/prg/SJNet_filemanager.py @@ -4,16 +4,27 @@ Keys: Up/Down or k/j Navigate Enter Open (folder -> cd, file -> menu) - / Search (live-filter) + / Search (live filter while typing) Backspace or h Go to parent directory . 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 + +Symlinks to folders and files are followed: folder links can be entered +like real directories. Broken symlinks are not listed. """ import curses +import locale import os import shlex +import shutil import subprocess +from collections.abc import Callable from pathlib import Path @@ -34,7 +45,10 @@ FILE_CATEGORIES = { ".mp3", ".wav", ".flac", ".ogg", ".m4a" }, "Dokumente": { - ".pdf", ".doc", ".docx", ".odt", ".txt", ".rtf" + ".doc", ".docx", ".odt", ".txt", ".rtf" + }, + "PDF": { + ".pdf" }, "Tabellen": { ".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() for category, extensions in FILE_CATEGORIES.items(): @@ -77,25 +91,31 @@ def human_size(n: int) -> str: return f"{int(size)}T" -def list_entries(cwd: Path, show_hidden: bool): - """Return [.., dir1, dir2, ..., file1, file2, ...].""" +def list_entries(cwd: Path, show_hidden: bool) -> tuple[list[Path], str | None]: + """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: all_entries = list(cwd.iterdir()) - except PermissionError: - return [PARENT] + except OSError as e: + return [PARENT], str(e) or type(e).__name__ 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)), + (e for e in all_entries if e.is_dir(follow_symlinks=True)), key=lambda e: e.name.lower(), ) 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(), ) - return [PARENT] + dirs + files + return [PARENT] + dirs + files, None 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 ------------------------------------------------------------ -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). + 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 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 while True: - ch = stdscr.getch() - if ch == 27: # bare ESC (no following escape-sequence bytes) + ch = stdscr.get_wch() + if ch in (27, -1): # bare ESC (get_wch reports -1 for ESC) return None if ch in (10, 13, curses.KEY_ENTER): break - if ch in (curses.KEY_BACKSPACE, 127, 8): + if ch in (curses.KEY_BACKSPACE, 127, 8) or ch in ("\x7f", "\x08"): 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. + elif isinstance(ch, str) and 32 <= ord(ch) != 127: + # Printable ASCII plus Unicode (umlauts, ...). + # Control characters (< 0x20) and DEL are 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.clrtoeol() stdscr.refresh() @@ -164,6 +197,14 @@ def prompt_line(stdscr, prompt: str, initial: str = "") -> str | None: 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 ------------------------------------------------------- def init_colors() -> dict[str, int]: @@ -194,7 +235,18 @@ def init_colors() -> dict[str, int]: 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() 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 else: name = entry.name - is_dir = entry.is_dir(follow_symlinks=False) + is_dir = entry.is_dir(follow_symlinks=True) full = entry 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 # 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: stdscr.addnstr(max_y - 1, 0, footer, max_x - 1, curses.A_DIM) 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. 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. 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 ------------------------------------------------------- -def main(stdscr): +def main(stdscr: curses.window) -> None: colors = init_colors() curses.curs_set(0) stdscr.keypad(True) @@ -502,10 +556,15 @@ def main(stdscr): status_kind = "status_ok" query = None filtered = None # when not None, used for display (search active) + copy_source: Path | None = None + move_source: Path | None = None while True: 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: # Re-apply filter against the fresh entries @@ -578,7 +637,7 @@ def main(stdscr): selected = 0 filtered = None query = None - elif entry.is_dir(follow_symlinks=False): + elif entry.is_dir(follow_symlinks=True): try: os.chdir(entry) cwd = Path.cwd() @@ -592,16 +651,40 @@ def main(stdscr): status = f"Cannot enter {entry.name}: {e}" status_kind = "status_err" else: - if entry.is_file(follow_symlinks=False): + if entry.is_file(follow_symlinks=True): 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 + new_cwd = Path.cwd() + if new_cwd != cwd: + cwd = new_cwd + selected = 0 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: # cancel search filtered = None @@ -625,17 +708,171 @@ def main(stdscr): selected = 0 # Re-apply search if active if query: + fresh, _err = list_entries(cwd, show_hidden) qlow = query.lower() 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 [] + 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: 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: curses.wrapper(main) except KeyboardInterrupt: