From 25ec2bde43f54286d78b63e680615effbc2e9d31 Mon Sep 17 00:00:00 2001 From: lohhiiccc <96543753+lohhiiccc@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:27:29 +0200 Subject: [PATCH] feat: implement item model and ncurses UI --- src/App.py | 60 ++++++++++++++++++++++++++++++++++++++++++++ src/UI.py | 36 ++++++++++++++++++++++++++ src/items/AItems.py | 46 +++++++++++++++++++++++++++++++++ src/items/File.py | 10 ++++++++ src/items/Folder.py | 26 +++++++++++++++++++ src/items/Symlink.py | 28 +++++++++++++++++++++ src/items/factory.py | 12 +++++++++ src/main.py | 46 +++++++++++---------------------- 8 files changed, 233 insertions(+), 31 deletions(-) create mode 100644 src/App.py create mode 100644 src/UI.py create mode 100644 src/items/factory.py diff --git a/src/App.py b/src/App.py new file mode 100644 index 0000000..7b35cc1 --- /dev/null +++ b/src/App.py @@ -0,0 +1,60 @@ +from .items.Folder import Folder +from pathlib import Path +from .UI import UI +import curses + + +class App(): + """ + Raise: + FileNotFoundError can append in __init__ + """ + def __init__(self, path: Path) -> None: + self._history = [] + self._items = [] + self.history.append(path) + self._current = Folder(path) + self._items = self.current.children() + self._UI = UI(self) + + 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) + stdscr.clear() + while (True): + try: + self._current = Folder(self.history[-1]) + except FileNotFoundError: + pass # doubt + stdscr.addstr(0, 0, str(self.history[-1])) + self.UI.render(stdscr) + stdscr.refresh() + key = stdscr.getkey() + self._items = self.current.children() + if 'q' == key: + break + + def run(self) -> None: + curses.wrapper(self._run) + + @property + def history(self) -> list: + return self._history + + @property + def items(self) -> list: + return self._items + + @property + def current(self) -> Folder: + return self._current + + @property + def UI(self) -> UI: + return self._UI + diff --git a/src/UI.py b/src/UI.py new file mode 100644 index 0000000..2b010b1 --- /dev/null +++ b/src/UI.py @@ -0,0 +1,36 @@ +from __future__ import annotations +import stat +from typing import TYPE_CHECKING +import curses +from .items.Symlink import Symlink + +if TYPE_CHECKING: + from .App import App + + +class UI: + def __init__(self, app: App): + self._app = app + + def render(self, stdscr: curses.window): + height, width = stdscr.getmaxyx() + for i in range(min(height, len(self.app.items))): + item = self.app.items[i] + name, size, perm = item.fields() + + stdscr.attron(curses.color_pair(item.color_pair)) + stdscr.addstr(i, 0, name) + + if type(item) is Symlink: + stdscr.attron(curses.color_pair(0)) + stdscr.addstr(i, len(name), " -> ") + stdscr.attron(curses.color_pair(Symlink.target_color(item.point_to))) + stdscr.addstr(i, len(name) + 4, str(item.point_to)) + + stdscr.addstr(i, int(width / 3), str(size)) + stdscr.addstr(i, int(width / 3) * 2, stat.filemode(perm)) + stdscr.attroff(curses.color_pair(item.color_pair)) + + @property + def app(self) -> App: + return self._app diff --git a/src/items/AItems.py b/src/items/AItems.py index e69de29..b8c6113 100644 --- a/src/items/AItems.py +++ b/src/items/AItems.py @@ -0,0 +1,46 @@ +from abc import ABC, abstractmethod +from pathlib import Path + + +class AItem(ABC): + """ + Raises: + FileNotFoundError: if the given path does not exist on the FS + PermissionError: if the process lacks permission to read the file metadata + """ + def __init__(self, path: Path) -> None: + self._path = path + self.update() + + COLOR = 0 + + def update(self) -> None: + self._stat = self._path.lstat() + self._permissions = self._stat.st_mode + + def fields(self) -> tuple[str, int, int]: + return (self.name, self.size, self.permissions) + + def __str__(self) -> str: + return f"{self.name}, {self.size}, {self.permissions}" + + @property + def path(self) -> Path: + return self._path + + @property + def name(self) -> str: + return self._path.name + + @property + def size(self) -> int: + return self._stat.st_size + + @property + def permissions(self) -> int: + return self._permissions + + @property + def color_pair(self) -> int: + return self.COLOR + diff --git a/src/items/File.py b/src/items/File.py index e69de29..8920973 100644 --- a/src/items/File.py +++ b/src/items/File.py @@ -0,0 +1,10 @@ +from pathlib import Path +from .AItems import AItem + + +class File(AItem): + COLOR = 1 + + def __init__(self, path: Path) -> None: + super().__init__(path) + diff --git a/src/items/Folder.py b/src/items/Folder.py index e69de29..eb949e4 100644 --- a/src/items/Folder.py +++ b/src/items/Folder.py @@ -0,0 +1,26 @@ +from pathlib import Path +from .AItems import AItem + + +class Folder(AItem): + COLOR = 2 + + def __init__(self, path: Path) -> None: + super().__init__(path) + + def fields(self) -> tuple[str, int, int]: + return (f"{self.name}/", self.size, self.permissions) + + def __str__(self) -> str: + return f"{self.name}/, {self.size}, {self.permissions}" + + def children(self) -> list[AItem]: + from .factory import make_item + items: list[AItem] = [] + for entry in self.path.iterdir(): + try: + items.append(make_item(entry)) + except FileNotFoundError: + pass + return items + diff --git a/src/items/Symlink.py b/src/items/Symlink.py index e69de29..aafe0df 100644 --- a/src/items/Symlink.py +++ b/src/items/Symlink.py @@ -0,0 +1,28 @@ +from pathlib import Path +from .AItems import AItem +from .File import File +from .Folder import Folder + +class Symlink(AItem): + COLOR = 3 + def __init__(self, path: Path) -> None: + super().__init__(path) + self._point_to = path.resolve() + if False == self._point_to.exists(): + self._point_to = None + + def __str__(self) -> str: + return f"{self.name} -> {'(broken)' if None == self.point_to else self.point_to.name}" + + @staticmethod + def target_color(point_to: Path | None) -> int: + if point_to is None: + return 5 + elif point_to.is_dir(): + return Folder.COLOR + else: + return File.COLOR + + @property + def point_to(self) -> Path: + return self._point_to diff --git a/src/items/factory.py b/src/items/factory.py new file mode 100644 index 0000000..c0907f7 --- /dev/null +++ b/src/items/factory.py @@ -0,0 +1,12 @@ +from .AItems import AItem +from .File import File +from .Folder import Folder +from .Symlink import Symlink +from pathlib import Path + +def make_item(path: Path) -> AItem: + if path.is_symlink(): + return Symlink(path) + if path.is_file(): + return File(path) + return Folder(path) diff --git a/src/main.py b/src/main.py index b3112a3..07f7c1e 100644 --- a/src/main.py +++ b/src/main.py @@ -1,35 +1,19 @@ -import curses -from curses import wrapper +from .App import App +from pathlib import Path +import sys +def main() -> int: + path: None | Path = None + try: + path = Path(sys.argv[1]) + except IndexError: + print(f"Usage: {sys.argv[0]} ") + return 1 -def init_curse() -> None: - curses.curs_set(0) - curses.start_color() - curses.use_default_colors() + pyExp = App(path) + pyExp.run() + return 0 - curses.init_pair(1, curses.COLOR_YELLOW, -1) # fg, bg - curses.init_pair(2, curses.COLOR_CYAN, -1) - curses.init_pair(3, curses.COLOR_GREEN, -1) +if __name__ == "__main__": + main() - -def print_folder(stdscr: curses.window, path: str) -> None: - stdscr.clear() - - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(0, 1, path) - stdscr.attroff(curses.color_pair(1)) - - for i in range(1, 9): - stdscr.attron(curses.color_pair(2)) - stdscr.addstr(i, 0, " f - a") - stdscr.attroff(curses.color_pair(2)) - - -def main(stdscr): - init_curse() - print_folder(stdscr, "/tmp") - stdscr.refresh() - stdscr.getkey() - - -wrapper(main)