feat: add configurable theme with semantic color roles

This commit is contained in:
lohhiiccc 2026-07-02 19:33:27 +02:00
parent 9a1d620556
commit f94771c67a
8 changed files with 80 additions and 21 deletions

View file

@ -14,10 +14,10 @@ class UI:
self._app = app self._app = app
HEADER_ROWS = 2 HEADER_ROWS = 2
ERROR_COLOR = 5
def _get_attr(self, item: any, index: int) -> str: 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: if index == self.app.navigator.cursor.pos:
attr |= curses.A_REVERSE attr |= curses.A_REVERSE
return attr return attr
@ -29,8 +29,9 @@ class UI:
stdscr.addstr(0, 0, str(navigator.history[-1])[:width - 1]) stdscr.addstr(0, 0, str(navigator.history[-1])[:width - 1])
if navigator.error: if navigator.error:
error_pair = self.app.config.theme.pair_for("error")
stdscr.addstr(1, 0, navigator.error[:width - 1], stdscr.addstr(1, 0, navigator.error[:width - 1],
curses.color_pair(self.ERROR_COLOR)) curses.color_pair(error_pair))
items = navigator.items items = navigator.items
available = max(0, height - self.HEADER_ROWS) available = max(0, height - self.HEADER_ROWS)
@ -62,8 +63,10 @@ class UI:
stdscr.addstr(row, 0, name.ljust(col_width), attr) 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(
Symlink.target_role(item.point_to))
attr_arrow = curses.color_pair(0) 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: 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

View file

@ -23,11 +23,7 @@ class App():
curses.curs_set(0) curses.curs_set(0)
curses.start_color() curses.start_color()
curses.use_default_colors() curses.use_default_colors()
curses.init_pair(1, curses.COLOR_WHITE, -1) self.config.theme.init_curses()
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)
stdscr.clear() stdscr.clear()
while (True): while (True):
self.navigator.refresh() self.navigator.refresh()

View file

@ -4,6 +4,7 @@ import sys
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from . import Sort from . import Sort
from .Theme import Theme
DEFAULT_KEYBINDS: dict[str, list[str]] = { DEFAULT_KEYBINDS: dict[str, list[str]] = {
"quit": ["q"], "quit": ["q"],
@ -20,10 +21,11 @@ DEFAULT_CURSOR_RESET = "top"
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) -> None: sort_mode: str, theme: Theme) -> None:
self._keybinds = keybinds self._keybinds = keybinds
self._cursor_reset = cursor_reset self._cursor_reset = cursor_reset
self._sort_mode = sort_mode self._sort_mode = sort_mode
self._theme = theme
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,7 +53,8 @@ class Config:
print(f"pyexplorer: unknown sort mode {sort_mode!r}, " print(f"pyexplorer: unknown sort mode {sort_mode!r}, "
f"falling back to {Sort.DEFAULT_MODE!r}", file=sys.stderr) f"falling back to {Sort.DEFAULT_MODE!r}", file=sys.stderr)
sort_mode = Sort.DEFAULT_MODE 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 @staticmethod
def _default_path() -> Path: def _default_path() -> Path:
@ -71,3 +74,7 @@ class Config:
@property @property
def sort_mode(self) -> str: def sort_mode(self) -> str:
return self._sort_mode return self._sort_mode
@property
def theme(self) -> Theme:
return self._theme

53
src/core/Theme.py Normal file
View 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]

View file

@ -15,7 +15,7 @@ class AItem(ABC):
self._path = path self._path = path
self.update() self.update()
COLOR = 0 COLOR_ROLE = "file"
def update(self) -> None: def update(self) -> None:
self._stat = self._path.lstat() self._stat = self._path.lstat()
@ -48,5 +48,5 @@ class AItem(ABC):
return self._permissions return self._permissions
@property @property
def color_pair(self) -> int: def color_role(self) -> str:
return self.COLOR return self.COLOR_ROLE

View file

@ -3,7 +3,7 @@ from .AItems import AItem
class File(AItem): class File(AItem):
COLOR = 1 COLOR_ROLE = "file"
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
super().__init__(path) super().__init__(path)

View file

@ -3,7 +3,7 @@ from .AItems import AItem
class Folder(AItem): class Folder(AItem):
COLOR = 2 COLOR_ROLE = "folder"
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
super().__init__(path) super().__init__(path)

View file

@ -5,7 +5,7 @@ from .Folder import Folder
class Symlink(AItem): class Symlink(AItem):
COLOR = 3 COLOR_ROLE = "symlink"
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
super().__init__(path) super().__init__(path)
@ -18,13 +18,13 @@ class Symlink(AItem):
self.point_to.name}" self.point_to.name}"
@staticmethod @staticmethod
def target_color(point_to: Path | None) -> int: def target_role(point_to: Path | None) -> str:
if point_to is None: if point_to is None:
return 5 return "broken_symlink"
elif point_to.is_dir(): elif point_to.is_dir():
return Folder.COLOR return Folder.COLOR_ROLE
else: else:
return File.COLOR return File.COLOR_ROLE
@property @property
def point_to(self) -> Path: def point_to(self) -> Path: