diff --git a/src/IO/Input.py b/src/IO/Input.py index 8e803b5..24b2a5b 100644 --- a/src/IO/Input.py +++ b/src/IO/Input.py @@ -1,9 +1,15 @@ from __future__ import annotations from typing import TYPE_CHECKING +from ..items.Folder import Folder +from ..core.Prompt import Prompt if TYPE_CHECKING: from .App import App +BACKSPACE_KEYS = ("KEY_BACKSPACE", "\x7f", "\x08") +ENTER_KEYS = ("\n", "KEY_ENTER") +ESCAPE_KEY = "\x1b" + class Input: def __init__(self, app: App) -> None: @@ -16,13 +22,40 @@ class Input: "back": self._back, "parent": self._parent, "sort": self._sort, + "new_folder": self._new_folder, + "delete_folder": self._delete_folder, + "new_file": self._new_file, } def handle(self, key: str) -> bool: + if self.app.prompt is not None: + return self._handle_prompt(key) action = self.app.config.action_for(key) handler = self._actions.get(action) return handler() if handler else True + def _handle_prompt(self, key: str) -> bool: + prompt = self.app.prompt + + if prompt.kind == "confirm": + if key in ("y", "Y"): + self.app.close_prompt() + prompt.on_submit(True) + elif key in ("n", "N", ESCAPE_KEY): + self.app.close_prompt() + return True + + if key in ENTER_KEYS: + self.app.close_prompt() + prompt.on_submit(prompt.buffer) + elif key == ESCAPE_KEY: + self.app.close_prompt() + elif key in BACKSPACE_KEYS: + prompt.buffer = prompt.buffer[:-1] + elif len(key) == 1 and key.isprintable(): + prompt.buffer += key + return True + def _quit(self) -> bool: return False @@ -54,6 +87,57 @@ class Input: self.app.navigator.cycle_sort() return True + def _new_folder(self) -> bool: + navigator = self.app.navigator + + def on_submit(name: str) -> None: + if not name: + return + try: + self.app.file_actions.create_folder(navigator.current.path, + name) + except OSError as e: + navigator.set_action_error(str(e)) + + self.app.open_prompt(Prompt("New folder name: ", "text", on_submit)) + return True + + def _delete_folder(self) -> bool: + navigator = self.app.navigator + items = navigator.items + if not items: + return True + item = items[navigator.cursor.pos] + if not isinstance(item, Folder): + return True + + def on_confirm(confirmed: bool) -> None: + if not confirmed: + return + try: + self.app.file_actions.delete_folder(item.path) + except OSError as e: + navigator.set_action_error(str(e)) + + self.app.open_prompt( + Prompt(f"Delete '{item.name}'? (y/n) ", "confirm", on_confirm)) + return True + + def _new_file(self) -> bool: + navigator = self.app.navigator + + def on_submit(name: str) -> None: + if not name: + return + try: + self.app.file_actions.create_file(navigator.current.path, + name) + except OSError as e: + navigator.set_action_error(str(e)) + + self.app.open_prompt(Prompt("New file name: ", "text", on_submit)) + return True + @property def app(self) -> App: return self._app diff --git a/src/IO/UI.py b/src/IO/UI.py index c69fba4..368b967 100644 --- a/src/IO/UI.py +++ b/src/IO/UI.py @@ -26,6 +26,7 @@ class UI: stdscr.erase() height, width = stdscr.getmaxyx() navigator = self.app.navigator + prompt = self.app.prompt stdscr.addstr(0, 0, str(navigator.history[-1])[:width - 1]) if navigator.error: @@ -33,12 +34,23 @@ class UI: stdscr.addstr(1, 0, navigator.error[:width - 1], curses.color_pair(error_pair)) + footer_rows = 1 if prompt else 0 items = navigator.items - available = max(0, height - self.HEADER_ROWS) + available = max(0, height - self.HEADER_ROWS - footer_rows) for index in range(min(available, len(items))): item = items[index] self._render_item(stdscr, item, index, index + self.HEADER_ROWS, width) + if prompt: + self._render_prompt(stdscr, prompt, height - 1, width) + + def _render_prompt(self, stdscr: curses.window, prompt, row: int, + width: int) -> None: + text = prompt.message + if prompt.kind == "text": + text += prompt.buffer + stdscr.addstr(row, 0, text[:width - 1]) + SIZE_UNITS = ("", "K", "M", "G", "T", "P") @staticmethod diff --git a/src/core/App.py b/src/core/App.py index 9d0ec51..0e1b70c 100644 --- a/src/core/App.py +++ b/src/core/App.py @@ -2,7 +2,9 @@ from pathlib import Path from ..IO.UI import UI from ..IO.Input import Input from .Config import Config +from .FileActions import FileActions from .Navigator import Navigator +from .Prompt import Prompt import curses @@ -16,6 +18,8 @@ class App(): def __init__(self, path: Path) -> None: self._config = Config.load() self._navigator = Navigator(path, self._config) + self._file_actions = FileActions() + self._prompt: Prompt | None = None self._UI = UI(self) self._input = Input(self) @@ -36,6 +40,20 @@ class App(): def run(self) -> None: curses.wrapper(self._run) + def open_prompt(self, prompt: Prompt) -> None: + self._prompt = prompt + + def close_prompt(self) -> None: + self._prompt = None + + @property + def prompt(self) -> Prompt | None: + return self._prompt + + @property + def file_actions(self) -> FileActions: + return self._file_actions + @property def config(self) -> Config: return self._config diff --git a/src/core/Config.py b/src/core/Config.py index 41c93ee..4d67432 100644 --- a/src/core/Config.py +++ b/src/core/Config.py @@ -14,6 +14,9 @@ DEFAULT_KEYBINDS: dict[str, list[str]] = { "back": ["-", "KEY_LEFT", "KEY_BACKSPACE", "\x7f", "\x08"], "parent": ["h"], "sort": ["i"], + "new_folder": ["d"], + "delete_folder": ["D"], + "new_file": ["%"], } DEFAULT_CURSOR_RESET = "top" diff --git a/src/core/FileActions.py b/src/core/FileActions.py new file mode 100644 index 0000000..47cb68a --- /dev/null +++ b/src/core/FileActions.py @@ -0,0 +1,14 @@ +from __future__ import annotations +import shutil +from pathlib import Path + + +class FileActions: + def create_folder(self, parent: Path, name: str) -> None: + (parent / name).mkdir() + + def create_file(self, parent: Path, name: str) -> None: + (parent / name).touch(exist_ok=False) + + def delete_folder(self, path: Path) -> None: + shutil.rmtree(path) diff --git a/src/core/Navigator.py b/src/core/Navigator.py index 8d446e3..993f522 100644 --- a/src/core/Navigator.py +++ b/src/core/Navigator.py @@ -17,6 +17,7 @@ class Navigator: self._current: Folder | None = None self._items: list[AItem] = [] self._error: str | None = None + self._action_error: str | None = None self._load() def _load(self) -> None: @@ -44,8 +45,12 @@ class Navigator: else: self._cursor.resete() + def set_action_error(self, message: str) -> None: + self._action_error = message + def _navigate_to(self, path: Path) -> None: self._history.append(path) + self._action_error = None self._load() self._reset_cursor() @@ -67,6 +72,7 @@ class Navigator: if len(self._history) <= 1: return False self._history.pop() + self._action_error = None self._load() self._reset_cursor() return True @@ -89,4 +95,4 @@ class Navigator: @property def error(self) -> str | None: - return self._error + return self._action_error or self._error diff --git a/src/core/Prompt.py b/src/core/Prompt.py new file mode 100644 index 0000000..bb121f5 --- /dev/null +++ b/src/core/Prompt.py @@ -0,0 +1,11 @@ +from __future__ import annotations +from typing import Callable + + +class Prompt: + def __init__(self, message: str, kind: str, + on_submit: Callable[[object], None]) -> None: + self.message = message + self.kind = kind + self.on_submit = on_submit + self.buffer = ""