feat: add configurable theme with semantic color roles
This commit is contained in:
parent
9a1d620556
commit
f94771c67a
8 changed files with 80 additions and 21 deletions
11
src/IO/UI.py
11
src/IO/UI.py
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
53
src/core/Theme.py
Normal 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]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue