Compare commits

...

9 commits

15 changed files with 535 additions and 66 deletions

View file

@ -1,21 +1,141 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..items.Folder import Folder
from ..core.Prompt import Prompt
if TYPE_CHECKING: if TYPE_CHECKING:
from .App import App from .App import App
BACKSPACE_KEYS = ("KEY_BACKSPACE", "\x7f", "\x08")
ENTER_KEYS = ("\n", "KEY_ENTER")
ESCAPE_KEY = "\x1b"
class Input: class Input:
def __init__(self, app: App) -> None: def __init__(self, app: App) -> None:
self._app = app self._app = app
self._actions = {
"quit": self._quit,
"down": self._down,
"up": self._up,
"enter": self._enter,
"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: def handle(self, key: str) -> bool:
if 'q' == key: if self.app.prompt is not None:
return False return self._handle_prompt(key)
elif key in ('j', 'KEY_DOWN'): action = self.app.config.action_for(key)
self.app.cursor.down(len(self.app.items)) handler = self._actions.get(action)
elif key in ('k', 'KEY_UP'): return handler() if handler else True
self.app.cursor.up()
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
def _down(self) -> bool:
navigator = self.app.navigator
navigator.cursor.down(len(navigator.items))
return True
def _up(self) -> bool:
self.app.navigator.cursor.up()
return True
def _enter(self) -> bool:
navigator = self.app.navigator
items = navigator.items
if items:
navigator.enter(items[navigator.cursor.pos])
return True
def _back(self) -> bool:
self.app.navigator.back()
return True
def _parent(self) -> bool:
self.app.navigator.up()
return True
def _sort(self) -> bool:
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 return True
@property @property

View file

@ -13,39 +13,80 @@ class UI:
def __init__(self, app: App): def __init__(self, app: App):
self._app = app self._app = app
def _get_attr(self, item: any, i: int) -> str: HEADER_ROWS = 2
attr = curses.color_pair(item.color_pair)
if i == self.app.cursor.pos: def _get_attr(self, item: any, index: int) -> str:
pair = self.app.config.theme.pair_for(item.color_role)
attr = curses.color_pair(pair)
if index == self.app.navigator.cursor.pos:
attr |= curses.A_REVERSE attr |= curses.A_REVERSE
return attr return attr
def render(self, stdscr: curses.window) -> None: def render(self, stdscr: curses.window) -> None:
stdscr.erase()
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
for i in range(min(height, len(self.app.items))): navigator = self.app.navigator
item = self.app.items[i] prompt = self.app.prompt
self._render_item(stdscr, item, i, width)
stdscr.addstr(0, 0, str(navigator.history[-1])[:width - 1])
if navigator.error:
error_pair = self.app.config.theme.pair_for("error")
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 - 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
def _format_size(size: int) -> str:
value = float(size)
for unit in UI.SIZE_UNITS:
if value < 1024:
return str(int(value)) if unit == "" else f"{value:.1f}{unit}"
value /= 1024
return f"{value:.1f}E"
ARROW = " -> " ARROW = " -> "
ARROW_LEN = len(ARROW) ARROW_LEN = len(ARROW)
def _render_item(self, stdscr: curses.window, item: AItem, i: int, width: int) -> None:
def _render_item(self, stdscr: curses.window, item: AItem, index: int,
row: int, width: int) -> None:
name, size, perm = item.fields() name, size, perm = item.fields()
name_len = len(name) name_len = len(name)
col_width = int(width / 3) col_width = int(width / 3)
attr = self._get_attr(item, i) attr = self._get_attr(item, index)
stdscr.addstr(i, 0, name.ljust(col_width), attr) stdscr.addstr(row, 0, name.ljust(col_width), attr)
if type(item) is Symlink: if type(item) is Symlink:
target_pair = self.app.config.theme.pair_for(
Symlink.target_role(item.point_to))
attr_arrow = curses.color_pair(0) attr_arrow = curses.color_pair(0)
attr_target = curses.color_pair(Symlink.target_color(item.point_to)) attr_target = curses.color_pair(target_pair)
if i == self.app.cursor.pos: if index == self.app.navigator.cursor.pos:
attr_arrow |= curses.A_REVERSE attr_arrow |= curses.A_REVERSE
attr_target |= curses.A_REVERSE attr_target |= curses.A_REVERSE
stdscr.addstr(i, name_len, self.ARROW, attr_arrow) stdscr.addstr(row, name_len, self.ARROW, attr_arrow)
stdscr.addstr(i, name_len + self.ARROW_LEN, str(item.point_to), attr_target) stdscr.addstr(row, name_len + self.ARROW_LEN, str(item.point_to), attr_target)
stdscr.addstr(i, col_width, str(size).ljust(col_width * 2), attr) stdscr.addstr(row, col_width, self._format_size(size).ljust(col_width * 2), attr)
stdscr.addstr(i, col_width * 2, stat.filemode(perm), attr) stdscr.addstr(row, col_width * 2, stat.filemode(perm), attr)
@property @property
def app(self) -> App: def app(self) -> App:

View file

@ -1,8 +1,10 @@
from ..items.Folder import Folder
from pathlib import Path from pathlib import Path
from ..IO.UI import UI from ..IO.UI import UI
from ..IO.Input import Input from ..IO.Input import Input
from .Cursor import Cursor from .Config import Config
from .FileActions import FileActions
from .Navigator import Navigator
from .Prompt import Prompt
import curses import curses
@ -14,51 +16,51 @@ class App():
""" """
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
self._history = [] self._config = Config.load()
self._items = [] self._navigator = Navigator(path, self._config)
self.history.append(path) self._file_actions = FileActions()
self._current = Folder(path) self._prompt: Prompt | None = None
self._items = self.current.children()
self._UI = UI(self) self._UI = UI(self)
self._input = Input(self) self._input = Input(self)
self._cursor = Cursor()
def _run(self, stdscr: curses.window) -> None: def _run(self, stdscr: curses.window) -> None:
curses.curs_set(0) curses.curs_set(0)
curses.start_color() curses.start_color()
curses.use_default_colors() curses.use_default_colors()
curses.init_pair(1, curses.COLOR_WHITE, -1) self.config.theme.init_curses()
curses.init_pair(2, curses.COLOR_BLUE, -1)
curses.init_pair(3, curses.COLOR_CYAN, -1)
curses.init_pair(4, curses.COLOR_YELLOW, -1)
stdscr.clear() stdscr.clear()
while (True): while (True):
try: self.navigator.refresh()
self._current = Folder(self.history[-1])
except FileNotFoundError:
pass
stdscr.addstr(0, 0, str(self.history[-1]))
self.UI.render(stdscr) self.UI.render(stdscr)
stdscr.refresh() stdscr.refresh()
key = stdscr.getkey() key = stdscr.getkey()
self._items = self.current.children()
if False is self.input.handle(key): if False is self.input.handle(key):
break break
def run(self) -> None: def run(self) -> None:
curses.wrapper(self._run) curses.wrapper(self._run)
@property def open_prompt(self, prompt: Prompt) -> None:
def history(self) -> list: self._prompt = prompt
return self._history
def close_prompt(self) -> None:
self._prompt = None
@property @property
def items(self) -> list: def prompt(self) -> Prompt | None:
return self._items return self._prompt
@property @property
def current(self) -> Folder: def file_actions(self) -> FileActions:
return self._current return self._file_actions
@property
def config(self) -> Config:
return self._config
@property
def navigator(self) -> Navigator:
return self._navigator
@property @property
def UI(self) -> UI: def UI(self) -> UI:
@ -67,7 +69,3 @@ class App():
@property @property
def input(self) -> Input: def input(self) -> Input:
return self._input return self._input
@property
def cursor(self) -> Cursor:
return self._cursor

83
src/core/Config.py Normal file
View file

@ -0,0 +1,83 @@
from __future__ import annotations
import os
import sys
import tomllib
from pathlib import Path
from . import Sort
from .Theme import Theme
DEFAULT_KEYBINDS: dict[str, list[str]] = {
"quit": ["q"],
"down": ["j", "KEY_DOWN"],
"up": ["k", "KEY_UP"],
"enter": ["l", "KEY_RIGHT", "\n", "KEY_ENTER"],
"back": ["-", "KEY_LEFT", "KEY_BACKSPACE", "\x7f", "\x08"],
"parent": ["h"],
"sort": ["i"],
"new_folder": ["d"],
"delete_folder": ["D"],
"new_file": ["%"],
}
DEFAULT_CURSOR_RESET = "top"
class Config:
def __init__(self, keybinds: dict[str, list[str]], cursor_reset: str,
sort_mode: str, theme: Theme) -> None:
self._keybinds = keybinds
self._cursor_reset = cursor_reset
self._sort_mode = sort_mode
self._theme = theme
self._key_to_action: dict[str, str] = {
key: action
for action, keys in keybinds.items()
for key in keys
}
@classmethod
def load(cls, path: Path | None = None) -> Config:
path = path or cls._default_path()
data: dict = {}
if path.is_file():
try:
with path.open("rb") as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
print(f"pyexplorer: ignoring invalid config {path}: {e}",
file=sys.stderr)
data = {}
keybinds = dict(DEFAULT_KEYBINDS)
keybinds.update(data.get("keybinds", {}))
cursor_reset = data.get("cursor", {}).get("reset", DEFAULT_CURSOR_RESET)
sort_mode = data.get("sort", {}).get("mode", Sort.DEFAULT_MODE)
if sort_mode not in Sort.MODES:
print(f"pyexplorer: unknown sort mode {sort_mode!r}, "
f"falling back to {Sort.DEFAULT_MODE!r}", file=sys.stderr)
sort_mode = Sort.DEFAULT_MODE
theme = Theme.load(data)
return cls(keybinds, cursor_reset, sort_mode, theme)
@staticmethod
def _default_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "pyexplorer" / "config.toml"
def keys_for(self, action: str) -> list[str]:
return self._keybinds.get(action, [])
def action_for(self, key: str) -> str | None:
return self._key_to_action.get(key)
@property
def cursor_reset(self) -> str:
return self._cursor_reset
@property
def sort_mode(self) -> str:
return self._sort_mode
@property
def theme(self) -> Theme:
return self._theme

View file

@ -4,7 +4,10 @@ class Cursor:
self._pos = 0 self._pos = 0
def resete(self) -> None: def resete(self) -> None:
set._pos = 0 self._pos = 0
def clamp(self, count: int) -> None:
self._pos = min(self._pos, max(count - 1, 0))
def up(self) -> None: def up(self) -> None:
self._pos -= 1 if self.pos >= 1 else 0 self._pos -= 1 if self.pos >= 1 else 0

14
src/core/FileActions.py Normal file
View file

@ -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)

98
src/core/Navigator.py Normal file
View file

@ -0,0 +1,98 @@
from pathlib import Path
from ..items.AItems import AItem
from ..items.Folder import Folder
from ..items.Symlink import Symlink
from . import Sort
from .Config import Config
from .Cursor import Cursor
class Navigator:
def __init__(self, path: Path, config: Config) -> None:
self._config = config
self._sort_mode = config.sort_mode
self._history = [path]
self._cursor = Cursor()
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:
self._error = None
try:
self._current = Folder(self._history[-1])
self._items = Sort.sort_items(self._current.children(),
self._sort_mode)
except FileNotFoundError:
self._items = []
except PermissionError:
self._items = []
self._error = f"Permission denied: {self._history[-1]}"
def refresh(self) -> None:
self._load()
def cycle_sort(self) -> None:
self._sort_mode = Sort.next_mode(self._sort_mode)
self._items = Sort.sort_items(self._items, self._sort_mode)
def _reset_cursor(self) -> None:
if self._config.cursor_reset == "keep":
self._cursor.clamp(len(self._items))
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()
def enter(self, item: AItem) -> bool:
if not item.is_navigable():
return False
path = item.point_to if isinstance(item, Symlink) else item.path
self._navigate_to(path)
return True
def up(self) -> bool:
parent = self._history[-1].parent
if parent == self._history[-1]:
return False
self._navigate_to(parent)
return True
def back(self) -> bool:
if len(self._history) <= 1:
return False
self._history.pop()
self._action_error = None
self._load()
self._reset_cursor()
return True
@property
def current(self) -> Folder:
return self._current
@property
def items(self) -> list[AItem]:
return self._items
@property
def history(self) -> list[Path]:
return self._history
@property
def cursor(self) -> Cursor:
return self._cursor
@property
def error(self) -> str | None:
return self._action_error or self._error

11
src/core/Prompt.py Normal file
View file

@ -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 = ""

33
src/core/Sort.py Normal file
View file

@ -0,0 +1,33 @@
from ..items.AItems import AItem
MODES = [
"name_asc",
"name_desc",
"dirs_first_asc",
"files_first_asc",
"dirs_first_desc",
"files_first_desc",
]
DEFAULT_MODE = "name_asc"
_DESC_MODES = ("name_desc", "dirs_first_desc", "files_first_desc")
_DIRS_FIRST_MODES = ("dirs_first_asc", "dirs_first_desc")
_FILES_FIRST_MODES = ("files_first_asc", "files_first_desc")
def sort_items(items: list[AItem], mode: str) -> list[AItem]:
items = sorted(items, key=lambda item: item.name, reverse=mode in _DESC_MODES)
if mode in _DIRS_FIRST_MODES:
items = sorted(items, key=lambda item: not item.is_navigable())
elif mode in _FILES_FIRST_MODES:
items = sorted(items, key=lambda item: item.is_navigable())
return items
def next_mode(mode: str) -> str:
try:
i = MODES.index(mode)
except ValueError:
return DEFAULT_MODE
return MODES[(i + 1) % len(MODES)]

53
src/core/Theme.py Normal file
View file

@ -0,0 +1,53 @@
from __future__ import annotations
import curses
import sys
COLOR_NAMES: dict[str, int] = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED,
"green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE,
}
ROLES = ["file", "folder", "symlink", "broken_symlink", "error"]
DEFAULT_COLORS: dict[str, str] = {
"file": "white",
"folder": "blue",
"symlink": "cyan",
"broken_symlink": "red",
"error": "red",
}
class Theme:
def __init__(self, colors: dict[str, str]) -> None:
self._colors = colors
self._pair_for_role = {role: i + 1 for i, role in enumerate(ROLES)}
@classmethod
def load(cls, data: dict) -> Theme:
colors = dict(DEFAULT_COLORS)
for role, name in data.get("theme", {}).items():
if role not in ROLES:
print(f"pyexplorer: unknown theme role {role!r}, ignoring",
file=sys.stderr)
continue
if name not in COLOR_NAMES:
print(f"pyexplorer: unknown color {name!r} for {role!r}, "
f"ignoring", file=sys.stderr)
continue
colors[role] = name
return cls(colors)
def init_curses(self) -> None:
for role in ROLES:
curses.init_pair(self._pair_for_role[role],
COLOR_NAMES[self._colors[role]], -1)
def pair_for(self, role: str) -> int:
return self._pair_for_role[role]

View file

@ -1,4 +1,4 @@
from abc import ABC from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
@ -15,7 +15,7 @@ class AItem(ABC):
self._path = path self._path = path
self.update() self.update()
COLOR = 0 COLOR_ROLE = "file"
def update(self) -> None: def update(self) -> None:
self._stat = self._path.lstat() self._stat = self._path.lstat()
@ -27,6 +27,10 @@ class AItem(ABC):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.name}, {self.size}, {self.permissions}" return f"{self.name}, {self.size}, {self.permissions}"
@abstractmethod
def is_navigable(self) -> bool:
...
@property @property
def path(self) -> Path: def path(self) -> Path:
return self._path return self._path
@ -44,5 +48,5 @@ class AItem(ABC):
return self._permissions return self._permissions
@property @property
def color_pair(self) -> int: def color_role(self) -> str:
return self.COLOR return self.COLOR_ROLE

View file

@ -3,7 +3,10 @@ from .AItems import AItem
class File(AItem): class File(AItem):
COLOR = 1 COLOR_ROLE = "file"
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
super().__init__(path) super().__init__(path)
def is_navigable(self) -> bool:
return False

View file

@ -3,7 +3,7 @@ from .AItems import AItem
class Folder(AItem): class Folder(AItem):
COLOR = 2 COLOR_ROLE = "folder"
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
super().__init__(path) super().__init__(path)
@ -20,6 +20,9 @@ class Folder(AItem):
for entry in self.path.iterdir(): for entry in self.path.iterdir():
try: try:
items.append(make_item(entry)) items.append(make_item(entry))
except FileNotFoundError: except (FileNotFoundError, PermissionError):
pass pass
return items return items
def is_navigable(self) -> bool:
return True

View file

@ -5,7 +5,7 @@ from .Folder import Folder
class Symlink(AItem): class Symlink(AItem):
COLOR = 3 COLOR_ROLE = "symlink"
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
super().__init__(path) super().__init__(path)
@ -18,14 +18,19 @@ class Symlink(AItem):
self.point_to.name}" self.point_to.name}"
@staticmethod @staticmethod
def target_color(point_to: Path | None) -> int: def target_role(point_to: Path | None) -> str:
if point_to is None: if point_to is None:
return 5 return "broken_symlink"
elif point_to.is_dir(): elif point_to.is_dir():
return Folder.COLOR return Folder.COLOR_ROLE
else: else:
return File.COLOR return File.COLOR_ROLE
@property @property
def point_to(self) -> Path: def point_to(self) -> Path:
return self._point_to return self._point_to
def is_navigable(self) -> bool:
if None is self.point_to:
return False
return self.point_to.is_dir()

View file

@ -8,6 +8,6 @@ from pathlib import Path
def make_item(path: Path) -> AItem: def make_item(path: Path) -> AItem:
if path.is_symlink(): if path.is_symlink():
return Symlink(path) return Symlink(path)
if path.is_file(): if path.is_dir():
return File(path) return Folder(path)
return Folder(path) return File(path)