Compare commits

..

No commits in common. "e0ca81802eaa35140fda473220ce8a90567fb8ae" and "4b721cb993339398dbde3745fee417bc1517485f" have entirely different histories.

7 changed files with 81 additions and 287 deletions

View file

@ -1,5 +1,4 @@
from __future__ import annotations from __future__ import annotations
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..items.Folder import Folder from ..items.Folder import Folder
from ..core.Prompt import Prompt from ..core.Prompt import Prompt
@ -22,16 +21,10 @@ class Input:
"enter": self._enter, "enter": self._enter,
"back": self._back, "back": self._back,
"parent": self._parent, "parent": self._parent,
"sort_direction": self._sort_direction, "sort": self._sort,
"sort_grouping": self._sort_grouping,
"new_folder": self._new_folder, "new_folder": self._new_folder,
"delete_folder": self._delete_folder, "delete_folder": self._delete_folder,
"new_file": self._new_file, "new_file": self._new_file,
"search": self._search,
"search_next": self._search_next,
"search_previous": self._search_previous,
"go_top": self._go_top,
"go_bottom": self._go_bottom,
} }
def handle(self, key: str) -> bool: def handle(self, key: str) -> bool:
@ -44,11 +37,6 @@ class Input:
def _handle_prompt(self, key: str) -> bool: def _handle_prompt(self, key: str) -> bool:
prompt = self.app.prompt prompt = self.app.prompt
if prompt.kind == "error":
self.app.close_prompt()
prompt.on_submit(None)
return True
if prompt.kind == "confirm": if prompt.kind == "confirm":
if key in ("y", "Y"): if key in ("y", "Y"):
self.app.close_prompt() self.app.close_prompt()
@ -80,45 +68,23 @@ class Input:
self.app.navigator.cursor.up() self.app.navigator.cursor.up()
return True return True
def _go_top(self) -> bool:
self.app.navigator.cursor.set(0)
return True
def _go_bottom(self) -> bool:
navigator = self.app.navigator
if navigator.items:
navigator.cursor.set(len(navigator.items) - 1)
return True
def _maybe_show_error(self) -> None:
error = self.app.navigator.error
if error:
self.app.open_prompt(Prompt.error(error))
def _enter(self) -> bool: def _enter(self) -> bool:
navigator = self.app.navigator navigator = self.app.navigator
items = navigator.items items = navigator.items
if items: if items:
navigator.enter(items[navigator.cursor.pos]) navigator.enter(items[navigator.cursor.pos])
self._maybe_show_error()
return True return True
def _back(self) -> bool: def _back(self) -> bool:
self.app.navigator.back() self.app.navigator.back()
self._maybe_show_error()
return True return True
def _parent(self) -> bool: def _parent(self) -> bool:
self.app.navigator.up() self.app.navigator.up()
self._maybe_show_error()
return True return True
def _sort_direction(self) -> bool: def _sort(self) -> bool:
self.app.navigator.cycle_sort_direction() self.app.navigator.cycle_sort()
return True
def _sort_grouping(self) -> bool:
self.app.navigator.cycle_sort_grouping()
return True return True
def _new_folder(self) -> bool: def _new_folder(self) -> bool:
@ -131,7 +97,7 @@ class Input:
self.app.file_actions.create_folder(navigator.current.path, self.app.file_actions.create_folder(navigator.current.path,
name) name)
except OSError as e: except OSError as e:
self.app.open_prompt(Prompt.error(str(e))) navigator.set_action_error(str(e))
self.app.open_prompt(Prompt("New folder name: ", "text", on_submit)) self.app.open_prompt(Prompt("New folder name: ", "text", on_submit))
return True return True
@ -151,7 +117,7 @@ class Input:
try: try:
self.app.file_actions.delete_folder(item.path) self.app.file_actions.delete_folder(item.path)
except OSError as e: except OSError as e:
self.app.open_prompt(Prompt.error(str(e))) navigator.set_action_error(str(e))
self.app.open_prompt( self.app.open_prompt(
Prompt(f"Delete '{item.name}'? (y/n) ", "confirm", on_confirm)) Prompt(f"Delete '{item.name}'? (y/n) ", "confirm", on_confirm))
@ -167,44 +133,11 @@ class Input:
self.app.file_actions.create_file(navigator.current.path, self.app.file_actions.create_file(navigator.current.path,
name) name)
except OSError as e: except OSError as e:
self.app.open_prompt(Prompt.error(str(e))) navigator.set_action_error(str(e))
self.app.open_prompt(Prompt("New file name: ", "text", on_submit)) self.app.open_prompt(Prompt("New file name: ", "text", on_submit))
return True return True
def _search(self) -> bool:
navigator = self.app.navigator
def on_submit(pattern: str) -> None:
if not pattern:
return
try:
regex = re.compile(pattern)
except re.error as e:
self.app.open_prompt(Prompt.error(f"Invalid regex: {e}"))
return
if not navigator.search(regex):
self.app.open_prompt(Prompt.error(f"No match: {pattern}"))
self.app.open_prompt(Prompt("Search: ", "text", on_submit))
return True
def _search_next(self) -> bool:
navigator = self.app.navigator
if navigator.last_pattern is None:
return True
if not navigator.search_next():
self.app.open_prompt(Prompt.error(f"No match: {navigator.last_pattern}"))
return True
def _search_previous(self) -> bool:
navigator = self.app.navigator
if navigator.last_pattern is None:
return True
if not navigator.search_previous():
self.app.open_prompt(Prompt.error(f"No match: {navigator.last_pattern}"))
return True
@property @property
def app(self) -> App: def app(self) -> App:
return self._app return self._app

View file

@ -12,10 +12,8 @@ if TYPE_CHECKING:
class UI: class UI:
def __init__(self, app: App): def __init__(self, app: App):
self._app = app self._app = app
self._scroll_top = 0
self._scroll_path = None
HEADER_ROWS = 1 HEADER_ROWS = 2
def _get_attr(self, item: any, index: int) -> str: def _get_attr(self, item: any, index: int) -> str:
pair = self.app.config.theme.pair_for(item.color_role) pair = self.app.config.theme.pair_for(item.color_role)
@ -28,80 +26,30 @@ class UI:
stdscr.erase() stdscr.erase()
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
navigator = self.app.navigator navigator = self.app.navigator
prompt = self.app.prompt
stdscr.addstr(0, 0, str(navigator.history[-1])[:width - 1]) 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 items = navigator.items
size_width = max((len(self._format_size(item.size)) for item in items), available = max(0, height - self.HEADER_ROWS - footer_rows)
default=0) for index in range(min(available, len(items))):
available = max(0, height - self.HEADER_ROWS) item = items[index]
top = self._update_scroll(navigator, available) self._render_item(stdscr, item, index, index + self.HEADER_ROWS, width)
for offset, item in enumerate(items[top:top + available]):
index = top + offset
self._render_item(stdscr, item, index, offset + self.HEADER_ROWS,
width, size_width)
prompt = self.app.prompt
if prompt: if prompt:
self._render_modal(stdscr, prompt, height, width) self._render_prompt(stdscr, prompt, height - 1, width)
def _update_scroll(self, navigator, available: int) -> int: def _render_prompt(self, stdscr: curses.window, prompt, row: int,
current_path = navigator.history[-1] width: int) -> None:
if current_path != self._scroll_path: text = prompt.message
self._scroll_path = current_path
self._scroll_top = 0
total = len(navigator.items)
if available <= 0 or total <= available:
self._scroll_top = 0
return self._scroll_top
offset = min(self.app.config.scroll_offset, (available - 1) // 2)
pos = navigator.cursor.pos
max_top = total - available
if pos < self._scroll_top + offset:
self._scroll_top = pos - offset
elif pos > self._scroll_top + available - 1 - offset:
self._scroll_top = pos - available + 1 + offset
self._scroll_top = max(0, min(self._scroll_top, max_top))
return self._scroll_top
MODAL_MAX_WIDTH = 60
MODAL_PADDING = 2
def _modal_lines(self, prompt) -> list[str]:
if prompt.kind == "text": if prompt.kind == "text":
return [prompt.message + prompt.buffer] text += prompt.buffer
if prompt.kind == "confirm": stdscr.addstr(row, 0, text[:width - 1])
return [prompt.message]
return [prompt.message, "(press any key)"]
def _render_modal(self, stdscr: curses.window, prompt,
screen_height: int, screen_width: int) -> None:
lines = self._modal_lines(prompt)
max_line = max(len(line) for line in lines)
inner_width = min(max_line, self.MODAL_MAX_WIDTH)
box_width = min(inner_width + 2 * self.MODAL_PADDING + 2,
screen_width - 2)
inner_width = box_width - 2 * self.MODAL_PADDING - 2
box_height = len(lines) + 2
top = max(0, (screen_height - box_height) // 2)
left = max(0, (screen_width - box_width) // 2)
attr = curses.A_NORMAL
if prompt.kind == "error":
attr = curses.color_pair(self.app.config.theme.pair_for("error"))
border = "+" + "-" * (box_width - 2) + "+"
pad = " " * self.MODAL_PADDING
stdscr.addstr(top, left, border, attr)
for i, line in enumerate(lines):
text = line[:inner_width].ljust(inner_width)
stdscr.addstr(top + 1 + i, left, "|" + pad + text + pad + "|", attr)
stdscr.addstr(top + box_height - 1, left, border, attr)
SIZE_UNITS = ("", "K", "M", "G", "T", "P") SIZE_UNITS = ("", "K", "M", "G", "T", "P")
@ -118,22 +66,13 @@ class UI:
ARROW_LEN = len(ARROW) ARROW_LEN = len(ARROW)
def _render_item(self, stdscr: curses.window, item: AItem, index: int, def _render_item(self, stdscr: curses.window, item: AItem, index: int,
row: int, width: int, size_width: int) -> None: row: int, width: int) -> None:
name, size, perm = item.fields() name, size, perm = item.fields()
attr = self._get_attr(item, index)
size_str = self._format_size(size).rjust(size_width)
stdscr.addstr(row, 0, size_str, attr)
name_col = size_width + 1
perm_str = stat.filemode(perm)
# -1 keeps the very last column free: writing to the bottom-right
# cell of the window makes curses raise addwstr() == ERR.
perm_col = max(name_col, width - len(perm_str) - 1)
name_width = max(0, perm_col - 1 - name_col)
name_len = len(name) name_len = len(name)
col_width = int(width / 3)
stdscr.addstr(row, name_col, name.ljust(name_width)[:name_width], attr) attr = self._get_attr(item, index)
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( target_pair = self.app.config.theme.pair_for(
@ -143,11 +82,11 @@ class UI:
if index == self.app.navigator.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(row, name_col + name_len, self.ARROW, attr_arrow) stdscr.addstr(row, name_len, self.ARROW, attr_arrow)
stdscr.addstr(row, name_col + name_len + self.ARROW_LEN, stdscr.addstr(row, name_len + self.ARROW_LEN, str(item.point_to), attr_target)
str(item.point_to), attr_target)
stdscr.addstr(row, perm_col, perm_str, 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 @property
def app(self) -> App: def app(self) -> App:

View file

@ -13,32 +13,22 @@ DEFAULT_KEYBINDS: dict[str, list[str]] = {
"enter": ["l", "KEY_RIGHT", "\n", "KEY_ENTER"], "enter": ["l", "KEY_RIGHT", "\n", "KEY_ENTER"],
"back": ["-", "KEY_LEFT", "KEY_BACKSPACE", "\x7f", "\x08"], "back": ["-", "KEY_LEFT", "KEY_BACKSPACE", "\x7f", "\x08"],
"parent": ["h"], "parent": ["h"],
"sort_direction": ["r"], "sort": ["i"],
"sort_grouping": ["i"],
"new_folder": ["d"], "new_folder": ["d"],
"delete_folder": ["D"], "delete_folder": ["D"],
"new_file": ["%"], "new_file": ["%"],
"search": ["/"],
"search_next": ["n"],
"search_previous": ["N"],
"go_top": ["g"],
"go_bottom": ["G"],
} }
DEFAULT_CURSOR_RESET = "top" DEFAULT_CURSOR_RESET = "top"
DEFAULT_SCROLL_OFFSET = 4
class Config: class Config:
def __init__(self, keybinds: dict[str, list[str]], cursor_reset: str, def __init__(self, keybinds: dict[str, list[str]], cursor_reset: str,
sort_direction: str, sort_grouping: str, theme: Theme, sort_mode: str, theme: Theme) -> None:
scroll_offset: int) -> None:
self._keybinds = keybinds self._keybinds = keybinds
self._cursor_reset = cursor_reset self._cursor_reset = cursor_reset
self._sort_direction = sort_direction self._sort_mode = sort_mode
self._sort_grouping = sort_grouping
self._theme = theme self._theme = theme
self._scroll_offset = scroll_offset
self._key_to_action: dict[str, str] = { self._key_to_action: dict[str, str] = {
key: action key: action
for action, keys in keybinds.items() for action, keys in keybinds.items()
@ -61,28 +51,13 @@ class Config:
keybinds = dict(DEFAULT_KEYBINDS) keybinds = dict(DEFAULT_KEYBINDS)
keybinds.update(data.get("keybinds", {})) keybinds.update(data.get("keybinds", {}))
cursor_reset = data.get("cursor", {}).get("reset", DEFAULT_CURSOR_RESET) cursor_reset = data.get("cursor", {}).get("reset", DEFAULT_CURSOR_RESET)
sort_mode = data.get("sort", {}).get("mode", Sort.DEFAULT_MODE)
sort_data = data.get("sort", {}) if sort_mode not in Sort.MODES:
sort_direction = sort_data.get("direction", Sort.DEFAULT_DIRECTION) print(f"pyexplorer: unknown sort mode {sort_mode!r}, "
if sort_direction not in Sort.DIRECTIONS: f"falling back to {Sort.DEFAULT_MODE!r}", file=sys.stderr)
print(f"pyexplorer: unknown sort direction {sort_direction!r}, " sort_mode = Sort.DEFAULT_MODE
f"falling back to {Sort.DEFAULT_DIRECTION!r}", file=sys.stderr)
sort_direction = Sort.DEFAULT_DIRECTION
sort_grouping = sort_data.get("grouping", Sort.DEFAULT_GROUPING)
if sort_grouping not in Sort.GROUPINGS:
print(f"pyexplorer: unknown sort grouping {sort_grouping!r}, "
f"falling back to {Sort.DEFAULT_GROUPING!r}", file=sys.stderr)
sort_grouping = Sort.DEFAULT_GROUPING
theme = Theme.load(data) theme = Theme.load(data)
scroll_offset = data.get("scroll", {}).get("offset", return cls(keybinds, cursor_reset, sort_mode, theme)
DEFAULT_SCROLL_OFFSET)
if not isinstance(scroll_offset, int) or scroll_offset < 0:
print(f"pyexplorer: invalid scroll offset {scroll_offset!r}, "
f"falling back to {DEFAULT_SCROLL_OFFSET}", file=sys.stderr)
scroll_offset = DEFAULT_SCROLL_OFFSET
return cls(keybinds, cursor_reset, sort_direction, sort_grouping,
theme, scroll_offset)
@staticmethod @staticmethod
def _default_path() -> Path: def _default_path() -> Path:
@ -100,17 +75,9 @@ class Config:
return self._cursor_reset return self._cursor_reset
@property @property
def sort_direction(self) -> str: def sort_mode(self) -> str:
return self._sort_direction return self._sort_mode
@property
def sort_grouping(self) -> str:
return self._sort_grouping
@property @property
def theme(self) -> Theme: def theme(self) -> Theme:
return self._theme return self._theme
@property
def scroll_offset(self) -> int:
return self._scroll_offset

View file

@ -9,9 +9,6 @@ class Cursor:
def clamp(self, count: int) -> None: def clamp(self, count: int) -> None:
self._pos = min(self._pos, max(count - 1, 0)) self._pos = min(self._pos, max(count - 1, 0))
def set(self, pos: int) -> None:
self._pos = pos
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

View file

@ -1,5 +1,3 @@
import os
import re
from pathlib import Path from pathlib import Path
from ..items.AItems import AItem from ..items.AItems import AItem
from ..items.Folder import Folder from ..items.Folder import Folder
@ -13,14 +11,13 @@ class Navigator:
def __init__(self, path: Path, config: Config) -> None: def __init__(self, path: Path, config: Config) -> None:
self._config = config self._config = config
self._sort_direction = config.sort_direction self._sort_mode = config.sort_mode
self._sort_grouping = config.sort_grouping
self._history = [path] self._history = [path]
self._cursor = Cursor() self._cursor = Cursor()
self._current: Folder | None = None self._current: Folder | None = None
self._items: list[AItem] = [] self._items: list[AItem] = []
self._error: str | None = None self._error: str | None = None
self._last_regex: re.Pattern | None = None self._action_error: str | None = None
self._load() self._load()
def _load(self) -> None: def _load(self) -> None:
@ -28,8 +25,7 @@ class Navigator:
try: try:
self._current = Folder(self._history[-1]) self._current = Folder(self._history[-1])
self._items = Sort.sort_items(self._current.children(), self._items = Sort.sort_items(self._current.children(),
self._sort_direction, self._sort_mode)
self._sort_grouping)
except FileNotFoundError: except FileNotFoundError:
self._items = [] self._items = []
except PermissionError: except PermissionError:
@ -39,46 +35,9 @@ class Navigator:
def refresh(self) -> None: def refresh(self) -> None:
self._load() self._load()
def _resort(self) -> None: def cycle_sort(self) -> None:
self._items = Sort.sort_items(self._items, self._sort_direction, self._sort_mode = Sort.next_mode(self._sort_mode)
self._sort_grouping) self._items = Sort.sort_items(self._items, self._sort_mode)
def cycle_sort_direction(self) -> None:
self._sort_direction = Sort.next_direction(self._sort_direction)
self._resort()
def cycle_sort_grouping(self) -> None:
self._sort_grouping = Sort.next_grouping(self._sort_grouping)
self._resort()
def search(self, regex: re.Pattern) -> bool:
self._last_regex = regex
return self._find(regex, 1)
def search_next(self) -> bool:
if self._last_regex is None:
return False
return self._find(self._last_regex, 1)
def search_previous(self) -> bool:
if self._last_regex is None:
return False
return self._find(self._last_regex, -1)
def _find(self, regex: re.Pattern, direction: int) -> bool:
count = len(self._items)
if count == 0:
return False
for step in range(1, count + 1):
index = (self._cursor.pos + direction * step) % count
if regex.search(self._items[index].name):
self._cursor.set(index)
return True
return False
@property
def last_pattern(self) -> str | None:
return self._last_regex.pattern if self._last_regex else None
def _reset_cursor(self) -> None: def _reset_cursor(self) -> None:
if self._config.cursor_reset == "keep": if self._config.cursor_reset == "keep":
@ -86,32 +45,34 @@ class Navigator:
else: else:
self._cursor.resete() self._cursor.resete()
def _navigate_to(self, path: Path) -> bool: def set_action_error(self, message: str) -> None:
if not os.access(path, os.R_OK | os.X_OK): self._action_error = message
self._error = f"Permission denied: {path}"
return False def _navigate_to(self, path: Path) -> None:
self._history.append(path) self._history.append(path)
self._error = None self._action_error = None
self._load() self._load()
self._reset_cursor() self._reset_cursor()
return True
def enter(self, item: AItem) -> bool: def enter(self, item: AItem) -> bool:
if not item.is_navigable(): if not item.is_navigable():
return False return False
path = item.point_to if isinstance(item, Symlink) else item.path path = item.point_to if isinstance(item, Symlink) else item.path
return self._navigate_to(path) self._navigate_to(path)
return True
def up(self) -> bool: def up(self) -> bool:
parent = self._history[-1].parent parent = self._history[-1].parent
if parent == self._history[-1]: if parent == self._history[-1]:
return False return False
return self._navigate_to(parent) self._navigate_to(parent)
return True
def back(self) -> bool: def back(self) -> bool:
if len(self._history) <= 1: if len(self._history) <= 1:
return False return False
self._history.pop() self._history.pop()
self._action_error = None
self._load() self._load()
self._reset_cursor() self._reset_cursor()
return True return True
@ -134,4 +95,4 @@ class Navigator:
@property @property
def error(self) -> str | None: def error(self) -> str | None:
return self._error return self._action_error or self._error

View file

@ -4,12 +4,8 @@ from typing import Callable
class Prompt: class Prompt:
def __init__(self, message: str, kind: str, def __init__(self, message: str, kind: str,
on_submit: Callable[[object], None] = lambda _: None) -> None: on_submit: Callable[[object], None]) -> None:
self.message = message self.message = message
self.kind = kind self.kind = kind
self.on_submit = on_submit self.on_submit = on_submit
self.buffer = "" self.buffer = ""
@classmethod
def error(cls, message: str) -> Prompt:
return cls(message, "error")

View file

@ -1,32 +1,33 @@
from ..items.AItems import AItem from ..items.AItems import AItem
DIRECTIONS = ["asc", "desc"] MODES = [
GROUPINGS = ["mixed", "dirs_first", "files_first"] "name_asc",
"name_desc",
"dirs_first_asc",
"files_first_asc",
"dirs_first_desc",
"files_first_desc",
]
DEFAULT_DIRECTION = "asc" DEFAULT_MODE = "name_asc"
DEFAULT_GROUPING = "mixed"
_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], direction: str, grouping: str) -> list[AItem]: def sort_items(items: list[AItem], mode: str) -> list[AItem]:
items = sorted(items, key=lambda item: item.name, reverse=direction == "desc") items = sorted(items, key=lambda item: item.name, reverse=mode in _DESC_MODES)
if grouping == "dirs_first": if mode in _DIRS_FIRST_MODES:
items = sorted(items, key=lambda item: not item.is_navigable()) items = sorted(items, key=lambda item: not item.is_navigable())
elif grouping == "files_first": elif mode in _FILES_FIRST_MODES:
items = sorted(items, key=lambda item: item.is_navigable()) items = sorted(items, key=lambda item: item.is_navigable())
return items return items
def next_direction(direction: str) -> str: def next_mode(mode: str) -> str:
try: try:
i = DIRECTIONS.index(direction) i = MODES.index(mode)
except ValueError: except ValueError:
return DEFAULT_DIRECTION return DEFAULT_MODE
return DIRECTIONS[(i + 1) % len(DIRECTIONS)] return MODES[(i + 1) % len(MODES)]
def next_grouping(grouping: str) -> str:
try:
i = GROUPINGS.index(grouping)
except ValueError:
return DEFAULT_GROUPING
return GROUPINGS[(i + 1) % len(GROUPINGS)]