Compare commits
9 commits
7d448de62a
...
4b721cb993
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b721cb993 | ||
|
|
f94771c67a | ||
|
|
9a1d620556 | ||
|
|
a48a13b1fd | ||
|
|
0c1d14bcb1 | ||
|
|
c0ff8ae70b | ||
|
|
abd3e2e98d | ||
|
|
37b14de04d | ||
|
|
017217de7c |
15 changed files with 535 additions and 66 deletions
130
src/IO/Input.py
130
src/IO/Input.py
|
|
@ -1,21 +1,141 @@
|
|||
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:
|
||||
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:
|
||||
if 'q' == key:
|
||||
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
|
||||
elif key in ('j', 'KEY_DOWN'):
|
||||
self.app.cursor.down(len(self.app.items))
|
||||
elif key in ('k', 'KEY_UP'):
|
||||
self.app.cursor.up()
|
||||
|
||||
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
|
||||
|
||||
@property
|
||||
|
|
|
|||
71
src/IO/UI.py
71
src/IO/UI.py
|
|
@ -13,39 +13,80 @@ class UI:
|
|||
def __init__(self, app: App):
|
||||
self._app = app
|
||||
|
||||
def _get_attr(self, item: any, i: int) -> str:
|
||||
attr = curses.color_pair(item.color_pair)
|
||||
if i == self.app.cursor.pos:
|
||||
HEADER_ROWS = 2
|
||||
|
||||
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
|
||||
return attr
|
||||
|
||||
def render(self, stdscr: curses.window) -> None:
|
||||
stdscr.erase()
|
||||
height, width = stdscr.getmaxyx()
|
||||
for i in range(min(height, len(self.app.items))):
|
||||
item = self.app.items[i]
|
||||
self._render_item(stdscr, item, i, width)
|
||||
navigator = self.app.navigator
|
||||
prompt = self.app.prompt
|
||||
|
||||
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_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_len = len(name)
|
||||
col_width = int(width / 3)
|
||||
|
||||
attr = self._get_attr(item, i)
|
||||
stdscr.addstr(i, 0, name.ljust(col_width), attr)
|
||||
attr = self._get_attr(item, index)
|
||||
stdscr.addstr(row, 0, name.ljust(col_width), attr)
|
||||
|
||||
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_target = curses.color_pair(Symlink.target_color(item.point_to))
|
||||
if i == self.app.cursor.pos:
|
||||
attr_target = curses.color_pair(target_pair)
|
||||
if index == self.app.navigator.cursor.pos:
|
||||
attr_arrow |= curses.A_REVERSE
|
||||
attr_target |= curses.A_REVERSE
|
||||
stdscr.addstr(i, 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, attr_arrow)
|
||||
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(i, col_width * 2, stat.filemode(perm), attr)
|
||||
stdscr.addstr(row, col_width, self._format_size(size).ljust(col_width * 2), attr)
|
||||
stdscr.addstr(row, col_width * 2, stat.filemode(perm), attr)
|
||||
|
||||
@property
|
||||
def app(self) -> App:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
from ..items.Folder import Folder
|
||||
from pathlib import Path
|
||||
from ..IO.UI import UI
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -14,51 +16,51 @@ class App():
|
|||
"""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self._history = []
|
||||
self._items = []
|
||||
self.history.append(path)
|
||||
self._current = Folder(path)
|
||||
self._items = self.current.children()
|
||||
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)
|
||||
self._cursor = Cursor()
|
||||
|
||||
def _run(self, stdscr: curses.window) -> None:
|
||||
curses.curs_set(0)
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
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)
|
||||
self.config.theme.init_curses()
|
||||
stdscr.clear()
|
||||
while (True):
|
||||
try:
|
||||
self._current = Folder(self.history[-1])
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
stdscr.addstr(0, 0, str(self.history[-1]))
|
||||
self.navigator.refresh()
|
||||
self.UI.render(stdscr)
|
||||
stdscr.refresh()
|
||||
key = stdscr.getkey()
|
||||
self._items = self.current.children()
|
||||
if False is self.input.handle(key):
|
||||
break
|
||||
|
||||
def run(self) -> None:
|
||||
curses.wrapper(self._run)
|
||||
|
||||
@property
|
||||
def history(self) -> list:
|
||||
return self._history
|
||||
def open_prompt(self, prompt: Prompt) -> None:
|
||||
self._prompt = prompt
|
||||
|
||||
def close_prompt(self) -> None:
|
||||
self._prompt = None
|
||||
|
||||
@property
|
||||
def items(self) -> list:
|
||||
return self._items
|
||||
def prompt(self) -> Prompt | None:
|
||||
return self._prompt
|
||||
|
||||
@property
|
||||
def current(self) -> Folder:
|
||||
return self._current
|
||||
def file_actions(self) -> FileActions:
|
||||
return self._file_actions
|
||||
|
||||
@property
|
||||
def config(self) -> Config:
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def navigator(self) -> Navigator:
|
||||
return self._navigator
|
||||
|
||||
@property
|
||||
def UI(self) -> UI:
|
||||
|
|
@ -67,7 +69,3 @@ class App():
|
|||
@property
|
||||
def input(self) -> Input:
|
||||
return self._input
|
||||
|
||||
@property
|
||||
def cursor(self) -> Cursor:
|
||||
return self._cursor
|
||||
|
|
|
|||
83
src/core/Config.py
Normal file
83
src/core/Config.py
Normal 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
|
||||
|
|
@ -4,7 +4,10 @@ class Cursor:
|
|||
self._pos = 0
|
||||
|
||||
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:
|
||||
self._pos -= 1 if self.pos >= 1 else 0
|
||||
|
|
|
|||
14
src/core/FileActions.py
Normal file
14
src/core/FileActions.py
Normal 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
98
src/core/Navigator.py
Normal 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
11
src/core/Prompt.py
Normal 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
33
src/core/Sort.py
Normal 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
53
src/core/Theme.py
Normal 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]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from abc import ABC
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ class AItem(ABC):
|
|||
self._path = path
|
||||
self.update()
|
||||
|
||||
COLOR = 0
|
||||
COLOR_ROLE = "file"
|
||||
|
||||
def update(self) -> None:
|
||||
self._stat = self._path.lstat()
|
||||
|
|
@ -27,6 +27,10 @@ class AItem(ABC):
|
|||
def __str__(self) -> str:
|
||||
return f"{self.name}, {self.size}, {self.permissions}"
|
||||
|
||||
@abstractmethod
|
||||
def is_navigable(self) -> bool:
|
||||
...
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self._path
|
||||
|
|
@ -44,5 +48,5 @@ class AItem(ABC):
|
|||
return self._permissions
|
||||
|
||||
@property
|
||||
def color_pair(self) -> int:
|
||||
return self.COLOR
|
||||
def color_role(self) -> str:
|
||||
return self.COLOR_ROLE
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ from .AItems import AItem
|
|||
|
||||
|
||||
class File(AItem):
|
||||
COLOR = 1
|
||||
COLOR_ROLE = "file"
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__(path)
|
||||
|
||||
def is_navigable(self) -> bool:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from .AItems import AItem
|
|||
|
||||
|
||||
class Folder(AItem):
|
||||
COLOR = 2
|
||||
COLOR_ROLE = "folder"
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__(path)
|
||||
|
|
@ -20,6 +20,9 @@ class Folder(AItem):
|
|||
for entry in self.path.iterdir():
|
||||
try:
|
||||
items.append(make_item(entry))
|
||||
except FileNotFoundError:
|
||||
except (FileNotFoundError, PermissionError):
|
||||
pass
|
||||
return items
|
||||
|
||||
def is_navigable(self) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from .Folder import Folder
|
|||
|
||||
|
||||
class Symlink(AItem):
|
||||
COLOR = 3
|
||||
COLOR_ROLE = "symlink"
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
super().__init__(path)
|
||||
|
|
@ -18,14 +18,19 @@ class Symlink(AItem):
|
|||
self.point_to.name}"
|
||||
|
||||
@staticmethod
|
||||
def target_color(point_to: Path | None) -> int:
|
||||
def target_role(point_to: Path | None) -> str:
|
||||
if point_to is None:
|
||||
return 5
|
||||
return "broken_symlink"
|
||||
elif point_to.is_dir():
|
||||
return Folder.COLOR
|
||||
return Folder.COLOR_ROLE
|
||||
else:
|
||||
return File.COLOR
|
||||
return File.COLOR_ROLE
|
||||
|
||||
@property
|
||||
def point_to(self) -> Path:
|
||||
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:
|
||||
if path.is_symlink():
|
||||
return Symlink(path)
|
||||
if path.is_file():
|
||||
return File(path)
|
||||
if path.is_dir():
|
||||
return Folder(path)
|
||||
return File(path)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue