From f94771c67aed8fc5af13c0b276fbd2c422e31c1b Mon Sep 17 00:00:00 2001 From: lohhiiccc <96543753+lohhiiccc@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:33:27 +0200 Subject: [PATCH] feat: add configurable theme with semantic color roles --- src/IO/UI.py | 11 +++++---- src/core/App.py | 6 +---- src/core/Config.py | 11 +++++++-- src/core/Theme.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ src/items/AItems.py | 6 ++--- src/items/File.py | 2 +- src/items/Folder.py | 2 +- src/items/Symlink.py | 10 ++++----- 8 files changed, 80 insertions(+), 21 deletions(-) create mode 100644 src/core/Theme.py diff --git a/src/IO/UI.py b/src/IO/UI.py index 67528f9..c69fba4 100644 --- a/src/IO/UI.py +++ b/src/IO/UI.py @@ -14,10 +14,10 @@ class UI: self._app = app HEADER_ROWS = 2 - ERROR_COLOR = 5 def _get_attr(self, item: any, index: int) -> str: - attr = curses.color_pair(item.color_pair) + 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 @@ -29,8 +29,9 @@ class UI: 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(self.ERROR_COLOR)) + curses.color_pair(error_pair)) items = navigator.items available = max(0, height - self.HEADER_ROWS) @@ -62,8 +63,10 @@ class UI: 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)) + attr_target = curses.color_pair(target_pair) if index == self.app.navigator.cursor.pos: attr_arrow |= curses.A_REVERSE attr_target |= curses.A_REVERSE diff --git a/src/core/App.py b/src/core/App.py index 3f18b4d..9d0ec51 100644 --- a/src/core/App.py +++ b/src/core/App.py @@ -23,11 +23,7 @@ class App(): 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) - curses.init_pair(5, curses.COLOR_RED, -1) + self.config.theme.init_curses() stdscr.clear() while (True): self.navigator.refresh() diff --git a/src/core/Config.py b/src/core/Config.py index e50578d..41c93ee 100644 --- a/src/core/Config.py +++ b/src/core/Config.py @@ -4,6 +4,7 @@ import sys import tomllib from pathlib import Path from . import Sort +from .Theme import Theme DEFAULT_KEYBINDS: dict[str, list[str]] = { "quit": ["q"], @@ -20,10 +21,11 @@ DEFAULT_CURSOR_RESET = "top" class Config: def __init__(self, keybinds: dict[str, list[str]], cursor_reset: str, - sort_mode: str) -> None: + 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() @@ -51,7 +53,8 @@ class Config: 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 - return cls(keybinds, cursor_reset, sort_mode) + theme = Theme.load(data) + return cls(keybinds, cursor_reset, sort_mode, theme) @staticmethod def _default_path() -> Path: @@ -71,3 +74,7 @@ class Config: @property def sort_mode(self) -> str: return self._sort_mode + + @property + def theme(self) -> Theme: + return self._theme diff --git a/src/core/Theme.py b/src/core/Theme.py new file mode 100644 index 0000000..adf008e --- /dev/null +++ b/src/core/Theme.py @@ -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] diff --git a/src/items/AItems.py b/src/items/AItems.py index 5d1d86d..9da5f69 100644 --- a/src/items/AItems.py +++ b/src/items/AItems.py @@ -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() @@ -48,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 diff --git a/src/items/File.py b/src/items/File.py index 7fb9cf7..6e7cc57 100644 --- a/src/items/File.py +++ b/src/items/File.py @@ -3,7 +3,7 @@ from .AItems import AItem class File(AItem): - COLOR = 1 + COLOR_ROLE = "file" def __init__(self, path: Path) -> None: super().__init__(path) diff --git a/src/items/Folder.py b/src/items/Folder.py index 414494e..3d48085 100644 --- a/src/items/Folder.py +++ b/src/items/Folder.py @@ -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) diff --git a/src/items/Symlink.py b/src/items/Symlink.py index bda2234..84e079e 100644 --- a/src/items/Symlink.py +++ b/src/items/Symlink.py @@ -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,13 +18,13 @@ 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: