Compare commits
7 commits
4b721cb993
...
e0ca81802e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ca81802e | ||
|
|
4dc693a9a4 | ||
|
|
a00381a2c2 | ||
|
|
b88ecce6f5 | ||
|
|
87c9a6f641 | ||
|
|
a144262f8c | ||
|
|
51724a1aa6 |
7 changed files with 288 additions and 82 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
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
|
||||||
|
|
@ -21,10 +22,16 @@ class Input:
|
||||||
"enter": self._enter,
|
"enter": self._enter,
|
||||||
"back": self._back,
|
"back": self._back,
|
||||||
"parent": self._parent,
|
"parent": self._parent,
|
||||||
"sort": self._sort,
|
"sort_direction": self._sort_direction,
|
||||||
|
"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:
|
||||||
|
|
@ -37,6 +44,11 @@ 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()
|
||||||
|
|
@ -68,23 +80,45 @@ 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(self) -> bool:
|
def _sort_direction(self) -> bool:
|
||||||
self.app.navigator.cycle_sort()
|
self.app.navigator.cycle_sort_direction()
|
||||||
|
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:
|
||||||
|
|
@ -97,7 +131,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:
|
||||||
navigator.set_action_error(str(e))
|
self.app.open_prompt(Prompt.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
|
||||||
|
|
@ -117,7 +151,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:
|
||||||
navigator.set_action_error(str(e))
|
self.app.open_prompt(Prompt.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))
|
||||||
|
|
@ -133,11 +167,44 @@ 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:
|
||||||
navigator.set_action_error(str(e))
|
self.app.open_prompt(Prompt.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
|
||||||
|
|
|
||||||
113
src/IO/UI.py
113
src/IO/UI.py
|
|
@ -12,8 +12,10 @@ 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 = 2
|
HEADER_ROWS = 1
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -26,30 +28,80 @@ 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
|
||||||
available = max(0, height - self.HEADER_ROWS - footer_rows)
|
size_width = max((len(self._format_size(item.size)) for item in items),
|
||||||
for index in range(min(available, len(items))):
|
default=0)
|
||||||
item = items[index]
|
available = max(0, height - self.HEADER_ROWS)
|
||||||
self._render_item(stdscr, item, index, index + self.HEADER_ROWS, width)
|
top = self._update_scroll(navigator, available)
|
||||||
|
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_prompt(stdscr, prompt, height - 1, width)
|
self._render_modal(stdscr, prompt, height, width)
|
||||||
|
|
||||||
def _render_prompt(self, stdscr: curses.window, prompt, row: int,
|
def _update_scroll(self, navigator, available: int) -> int:
|
||||||
width: int) -> None:
|
current_path = navigator.history[-1]
|
||||||
text = prompt.message
|
if current_path != self._scroll_path:
|
||||||
|
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":
|
||||||
text += prompt.buffer
|
return [prompt.message + prompt.buffer]
|
||||||
stdscr.addstr(row, 0, text[:width - 1])
|
if prompt.kind == "confirm":
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
@ -66,13 +118,22 @@ 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) -> None:
|
row: int, width: int, size_width: int) -> None:
|
||||||
name, size, perm = item.fields()
|
name, size, perm = item.fields()
|
||||||
name_len = len(name)
|
|
||||||
col_width = int(width / 3)
|
|
||||||
|
|
||||||
attr = self._get_attr(item, index)
|
attr = self._get_attr(item, index)
|
||||||
stdscr.addstr(row, 0, name.ljust(col_width), attr)
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
stdscr.addstr(row, name_col, name.ljust(name_width)[:name_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(
|
||||||
|
|
@ -82,11 +143,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_len, self.ARROW, attr_arrow)
|
stdscr.addstr(row, name_col + name_len, self.ARROW, attr_arrow)
|
||||||
stdscr.addstr(row, name_len + self.ARROW_LEN, str(item.point_to), attr_target)
|
stdscr.addstr(row, name_col + 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(row, perm_col, perm_str, attr)
|
||||||
stdscr.addstr(row, col_width * 2, stat.filemode(perm), attr)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app(self) -> App:
|
def app(self) -> App:
|
||||||
|
|
|
||||||
|
|
@ -13,22 +13,32 @@ 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": ["i"],
|
"sort_direction": ["r"],
|
||||||
|
"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_mode: str, theme: Theme) -> None:
|
sort_direction: str, sort_grouping: str, theme: Theme,
|
||||||
|
scroll_offset: int) -> None:
|
||||||
self._keybinds = keybinds
|
self._keybinds = keybinds
|
||||||
self._cursor_reset = cursor_reset
|
self._cursor_reset = cursor_reset
|
||||||
self._sort_mode = sort_mode
|
self._sort_direction = sort_direction
|
||||||
|
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()
|
||||||
|
|
@ -51,13 +61,28 @@ 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)
|
|
||||||
if sort_mode not in Sort.MODES:
|
sort_data = data.get("sort", {})
|
||||||
print(f"pyexplorer: unknown sort mode {sort_mode!r}, "
|
sort_direction = sort_data.get("direction", Sort.DEFAULT_DIRECTION)
|
||||||
f"falling back to {Sort.DEFAULT_MODE!r}", file=sys.stderr)
|
if sort_direction not in Sort.DIRECTIONS:
|
||||||
sort_mode = Sort.DEFAULT_MODE
|
print(f"pyexplorer: unknown sort direction {sort_direction!r}, "
|
||||||
|
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)
|
||||||
return cls(keybinds, cursor_reset, sort_mode, theme)
|
scroll_offset = data.get("scroll", {}).get("offset",
|
||||||
|
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:
|
||||||
|
|
@ -75,9 +100,17 @@ class Config:
|
||||||
return self._cursor_reset
|
return self._cursor_reset
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sort_mode(self) -> str:
|
def sort_direction(self) -> str:
|
||||||
return self._sort_mode
|
return self._sort_direction
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
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
|
||||||
|
|
@ -11,13 +13,14 @@ 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_mode = config.sort_mode
|
self._sort_direction = config.sort_direction
|
||||||
|
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._action_error: str | None = None
|
self._last_regex: re.Pattern | None = None
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
|
|
@ -25,7 +28,8 @@ 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_mode)
|
self._sort_direction,
|
||||||
|
self._sort_grouping)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
self._items = []
|
self._items = []
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
|
|
@ -35,9 +39,46 @@ class Navigator:
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def cycle_sort(self) -> None:
|
def _resort(self) -> None:
|
||||||
self._sort_mode = Sort.next_mode(self._sort_mode)
|
self._items = Sort.sort_items(self._items, self._sort_direction,
|
||||||
self._items = Sort.sort_items(self._items, self._sort_mode)
|
self._sort_grouping)
|
||||||
|
|
||||||
|
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":
|
||||||
|
|
@ -45,34 +86,32 @@ class Navigator:
|
||||||
else:
|
else:
|
||||||
self._cursor.resete()
|
self._cursor.resete()
|
||||||
|
|
||||||
def set_action_error(self, message: str) -> None:
|
def _navigate_to(self, path: Path) -> bool:
|
||||||
self._action_error = message
|
if not os.access(path, os.R_OK | os.X_OK):
|
||||||
|
self._error = f"Permission denied: {path}"
|
||||||
def _navigate_to(self, path: Path) -> None:
|
return False
|
||||||
self._history.append(path)
|
self._history.append(path)
|
||||||
self._action_error = None
|
self._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
|
||||||
self._navigate_to(path)
|
return 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
|
||||||
self._navigate_to(parent)
|
return 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
|
||||||
|
|
@ -95,4 +134,4 @@ class Navigator:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error(self) -> str | None:
|
def error(self) -> str | None:
|
||||||
return self._action_error or self._error
|
return self._error
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,12 @@ 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]) -> None:
|
on_submit: Callable[[object], None] = lambda _: 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")
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,32 @@
|
||||||
from ..items.AItems import AItem
|
from ..items.AItems import AItem
|
||||||
|
|
||||||
MODES = [
|
DIRECTIONS = ["asc", "desc"]
|
||||||
"name_asc",
|
GROUPINGS = ["mixed", "dirs_first", "files_first"]
|
||||||
"name_desc",
|
|
||||||
"dirs_first_asc",
|
|
||||||
"files_first_asc",
|
|
||||||
"dirs_first_desc",
|
|
||||||
"files_first_desc",
|
|
||||||
]
|
|
||||||
|
|
||||||
DEFAULT_MODE = "name_asc"
|
DEFAULT_DIRECTION = "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], mode: str) -> list[AItem]:
|
def sort_items(items: list[AItem], direction: str, grouping: str) -> list[AItem]:
|
||||||
items = sorted(items, key=lambda item: item.name, reverse=mode in _DESC_MODES)
|
items = sorted(items, key=lambda item: item.name, reverse=direction == "desc")
|
||||||
if mode in _DIRS_FIRST_MODES:
|
if grouping == "dirs_first":
|
||||||
items = sorted(items, key=lambda item: not item.is_navigable())
|
items = sorted(items, key=lambda item: not item.is_navigable())
|
||||||
elif mode in _FILES_FIRST_MODES:
|
elif grouping == "files_first":
|
||||||
items = sorted(items, key=lambda item: item.is_navigable())
|
items = sorted(items, key=lambda item: item.is_navigable())
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def next_mode(mode: str) -> str:
|
def next_direction(direction: str) -> str:
|
||||||
try:
|
try:
|
||||||
i = MODES.index(mode)
|
i = DIRECTIONS.index(direction)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return DEFAULT_MODE
|
return DEFAULT_DIRECTION
|
||||||
return MODES[(i + 1) % len(MODES)]
|
return DIRECTIONS[(i + 1) % len(DIRECTIONS)]
|
||||||
|
|
||||||
|
|
||||||
|
def next_grouping(grouping: str) -> str:
|
||||||
|
try:
|
||||||
|
i = GROUPINGS.index(grouping)
|
||||||
|
except ValueError:
|
||||||
|
return DEFAULT_GROUPING
|
||||||
|
return GROUPINGS[(i + 1) % len(GROUPINGS)]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue