Compare commits
No commits in common. "4b721cb993339398dbde3745fee417bc1517485f" and "7d448de62a995a4a22c6412688bc1527edb24605" have entirely different histories.
4b721cb993
...
7d448de62a
15 changed files with 66 additions and 535 deletions
130
src/IO/Input.py
130
src/IO/Input.py
|
|
@ -1,141 +1,21 @@
|
||||||
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 self.app.prompt is not None:
|
if 'q' == key:
|
||||||
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
|
return False
|
||||||
|
elif key in ('j', 'KEY_DOWN'):
|
||||||
def _down(self) -> bool:
|
self.app.cursor.down(len(self.app.items))
|
||||||
navigator = self.app.navigator
|
elif key in ('k', 'KEY_UP'):
|
||||||
navigator.cursor.down(len(navigator.items))
|
self.app.cursor.up()
|
||||||
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
|
||||||
|
|
|
||||||
71
src/IO/UI.py
71
src/IO/UI.py
|
|
@ -13,80 +13,39 @@ class UI:
|
||||||
def __init__(self, app: App):
|
def __init__(self, app: App):
|
||||||
self._app = app
|
self._app = app
|
||||||
|
|
||||||
HEADER_ROWS = 2
|
def _get_attr(self, item: any, i: int) -> str:
|
||||||
|
attr = curses.color_pair(item.color_pair)
|
||||||
def _get_attr(self, item: any, index: int) -> str:
|
if i == self.app.cursor.pos:
|
||||||
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()
|
||||||
navigator = self.app.navigator
|
for i in range(min(height, len(self.app.items))):
|
||||||
prompt = self.app.prompt
|
item = self.app.items[i]
|
||||||
|
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, index)
|
attr = self._get_attr(item, i)
|
||||||
stdscr.addstr(row, 0, name.ljust(col_width), attr)
|
stdscr.addstr(i, 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(target_pair)
|
attr_target = curses.color_pair(Symlink.target_color(item.point_to))
|
||||||
if index == self.app.navigator.cursor.pos:
|
if i == self.app.cursor.pos:
|
||||||
attr_arrow |= curses.A_REVERSE
|
attr_arrow |= curses.A_REVERSE
|
||||||
attr_target |= curses.A_REVERSE
|
attr_target |= curses.A_REVERSE
|
||||||
stdscr.addstr(row, name_len, self.ARROW, attr_arrow)
|
stdscr.addstr(i, name_len, self.ARROW, attr_arrow)
|
||||||
stdscr.addstr(row, name_len + self.ARROW_LEN, str(item.point_to), attr_target)
|
stdscr.addstr(i, name_len + self.ARROW_LEN, str(item.point_to), attr_target)
|
||||||
|
|
||||||
stdscr.addstr(row, col_width, self._format_size(size).ljust(col_width * 2), attr)
|
stdscr.addstr(i, col_width, str(size).ljust(col_width * 2), attr)
|
||||||
stdscr.addstr(row, col_width * 2, stat.filemode(perm), attr)
|
stdscr.addstr(i, col_width * 2, stat.filemode(perm), attr)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app(self) -> App:
|
def app(self) -> App:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
|
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 .Config import Config
|
from .Cursor import Cursor
|
||||||
from .FileActions import FileActions
|
|
||||||
from .Navigator import Navigator
|
|
||||||
from .Prompt import Prompt
|
|
||||||
import curses
|
import curses
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -16,51 +14,51 @@ class App():
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
self._config = Config.load()
|
self._history = []
|
||||||
self._navigator = Navigator(path, self._config)
|
self._items = []
|
||||||
self._file_actions = FileActions()
|
self.history.append(path)
|
||||||
self._prompt: Prompt | None = None
|
self._current = Folder(path)
|
||||||
|
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()
|
||||||
self.config.theme.init_curses()
|
curses.init_pair(1, curses.COLOR_WHITE, -1)
|
||||||
|
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):
|
||||||
self.navigator.refresh()
|
try:
|
||||||
|
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)
|
||||||
|
|
||||||
def open_prompt(self, prompt: Prompt) -> None:
|
@property
|
||||||
self._prompt = prompt
|
def history(self) -> list:
|
||||||
|
return self._history
|
||||||
def close_prompt(self) -> None:
|
|
||||||
self._prompt = None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def prompt(self) -> Prompt | None:
|
def items(self) -> list:
|
||||||
return self._prompt
|
return self._items
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def file_actions(self) -> FileActions:
|
def current(self) -> Folder:
|
||||||
return self._file_actions
|
return self._current
|
||||||
|
|
||||||
@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:
|
||||||
|
|
@ -69,3 +67,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -4,10 +4,7 @@ class Cursor:
|
||||||
self._pos = 0
|
self._pos = 0
|
||||||
|
|
||||||
def resete(self) -> None:
|
def resete(self) -> None:
|
||||||
self._pos = 0
|
set._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
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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 = ""
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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)]
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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]
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC
|
||||||
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_ROLE = "file"
|
COLOR = 0
|
||||||
|
|
||||||
def update(self) -> None:
|
def update(self) -> None:
|
||||||
self._stat = self._path.lstat()
|
self._stat = self._path.lstat()
|
||||||
|
|
@ -27,10 +27,6 @@ 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
|
||||||
|
|
@ -48,5 +44,5 @@ class AItem(ABC):
|
||||||
return self._permissions
|
return self._permissions
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def color_role(self) -> str:
|
def color_pair(self) -> int:
|
||||||
return self.COLOR_ROLE
|
return self.COLOR
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,7 @@ from .AItems import AItem
|
||||||
|
|
||||||
|
|
||||||
class File(AItem):
|
class File(AItem):
|
||||||
COLOR_ROLE = "file"
|
COLOR = 1
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from .AItems import AItem
|
||||||
|
|
||||||
|
|
||||||
class Folder(AItem):
|
class Folder(AItem):
|
||||||
COLOR_ROLE = "folder"
|
COLOR = 2
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
super().__init__(path)
|
super().__init__(path)
|
||||||
|
|
@ -20,9 +20,6 @@ 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, PermissionError):
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
return items
|
return items
|
||||||
|
|
||||||
def is_navigable(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from .Folder import Folder
|
||||||
|
|
||||||
|
|
||||||
class Symlink(AItem):
|
class Symlink(AItem):
|
||||||
COLOR_ROLE = "symlink"
|
COLOR = 3
|
||||||
|
|
||||||
def __init__(self, path: Path) -> None:
|
def __init__(self, path: Path) -> None:
|
||||||
super().__init__(path)
|
super().__init__(path)
|
||||||
|
|
@ -18,19 +18,14 @@ class Symlink(AItem):
|
||||||
self.point_to.name}"
|
self.point_to.name}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def target_role(point_to: Path | None) -> str:
|
def target_color(point_to: Path | None) -> int:
|
||||||
if point_to is None:
|
if point_to is None:
|
||||||
return "broken_symlink"
|
return 5
|
||||||
elif point_to.is_dir():
|
elif point_to.is_dir():
|
||||||
return Folder.COLOR_ROLE
|
return Folder.COLOR
|
||||||
else:
|
else:
|
||||||
return File.COLOR_ROLE
|
return File.COLOR
|
||||||
|
|
||||||
@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()
|
|
||||||
|
|
|
||||||
|
|
@ -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_dir():
|
if path.is_file():
|
||||||
return Folder(path)
|
|
||||||
return File(path)
|
return File(path)
|
||||||
|
return Folder(path)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue