add somethin to myrummy and fix SJNfilemanager
This commit is contained in:
+4
-2
@@ -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.
|
||||
|
||||
+1
-8
@@ -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):
|
||||
|
||||
+269
-32
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user