Compare commits

...

4 commits

7 changed files with 318 additions and 5 deletions

View file

@ -37,6 +37,14 @@ from .errors import (
)
from .icon_theme import (
FileIconTheme,
Icon,
IconTheme,
IconThemes,
MemoryIconTheme
)
from .misc import (
catch_errors,
convert_to_boolean,

View file

@ -235,7 +235,8 @@ class HttpStatus(IntEnum):
RequestRangeNotSatisfiable = 416
ExpectationFailed = 417
IAmATeapot = 418
TooHighToHandleYourShit = 420
EnhanceYourCalm = 420
TooHighToHandleYourShit = 420 # this will get removed in the future
MisdirectedRequest = 421
UnprocessableEntity = 422
Locked = 423

View file

@ -128,16 +128,16 @@ class Error(Exception):
class FileError(Error, metaclass = ErrorMeta):
"Raised on errors involving files"
NotFound = 0
NotFound: ErrorCode = 0 # type: ignore[assignment]
"Raised when a file or directory could not be found"
Found = 1
Found: ErrorCode = 1 # type: ignore[assignment]
"Raised when a file or directory exists when it should not"
IsDirectory = 2
IsDirectory: ErrorCode = 2 # type: ignore[assignment]
"Raised when the path is a directory when it should not be"
IsFile = 3
IsFile: ErrorCode = 3 # type: ignore[assignment]
"Raised when the path is a file when it should not be"

242
blib/icon_theme.py Normal file
View file

@ -0,0 +1,242 @@
from abc import ABC, abstractmethod
from typing import Any
from .errors import FileError
from .path import File
class Icon(dict[int, Any]):
"Represents an icon"
__slots__ = ("name",)
def __init__(self, name: str, icons: dict[int, Any] | None = None) -> None:
"""
Create a new ``Icon`` object
:param name: Name of the icon
:param icons: Icon data and their sizes
"""
dict.__init__(self, icons or {})
self.name: str = name
def __repr__(self) -> str:
sizes = ",".join(repr(size) for size in self.sizes)
return f"Icon({repr(self.name)}, sizes={sizes})"
@property
def sizes(self) -> tuple[int, ...]:
"Return a tuple of the available icon sizes"
return tuple(sorted(self.keys()))
@property
def small(self) -> Any:
"Return a 16x16 icon if available"
return self[16]
@property
def normal(self) -> Any:
"Return a 32x32 icon if available"
return self[32]
@property
def large(self) -> Any:
"Return a 64x64 icon if available"
return self[64]
@property
def extra_large(self) -> Any:
"Return a 128x128 icon if available"
return self[128]
@property
def scalable(self) -> Any:
"Return a scalable (usually svg) icon if available"
return self[0]
class IconTheme(ABC):
"Base class for icon themes"
name: str
"Name of the icon theme"
@abstractmethod
def get_icon(self, name: str) -> Icon: ...
class IconThemes(dict[str, IconTheme]):
"Represets a collection of icon themes"
def __init__(self, base_path: str = "/usr/share/icons", default: str = "hicolor") -> None:
"""
Create a new ``IconThemes`` object
:param base_path: Filesystem path to search for icon themes
:param default: Fallback theme to use when searching for icons
:raises ValueError: When the default theme cannot be found
"""
self.base_path: File = File(base_path).resolve()
"Filesystem path to search for icon themes"
self.themes: dict[str, IconTheme] = {}
"Loaded icon themes"
self.default: IconTheme = MemoryIconTheme("default")
"Fallback icon theme"
self.load(default)
def get_icon(self, theme: str, name: str) -> Icon:
"""
Get an icon. If the icon cannot be found in the specified theme, the default theme
will get searched.
:param theme: Icon theme to search
:param name: Name of the icon (case-insensitive)
:raises KeyError: If the icon cannot be found
"""
try:
self.themes[theme].get_icon(name)
except KeyError:
pass
return self.default.get_icon(name)
def load(self, default: str = "hicolor") -> None:
"""
Load themes from the base path
:params defualt: Fallback theme to use when searching for icons
"""
themes: dict[str, IconTheme]
fallback: IconTheme | None = None
for path in self.base_path.glob():
if path.isdir:
themes[path.name] = FileIconTheme(path)
if path.name.lower() == default.lower():
fallback = themes[path.name]
if fallback is None:
raise ValueError(f"Cannot find default theme: {default}")
self.default = fallback
self.themes = themes
class FileIconTheme(IconTheme):
"Represents an icon theme in a directory"
__slots__ = ("path", "name", "default", "_cache")
def __init__(self, base_path: str, name: str | None = None) -> None:
"""
Create a new ``FileIconTheme`` object
:param base_path: Path to the icon theme
:param name: Name of the icon theme
:raises blib.FileError: If the path is not a directory or does not exist
"""
self.path: File = File(base_path).resolve()
self.name: str = name or self.path.name
self._cache: dict[str, Icon | None] = {}
if not self.path.isdir:
raise FileError.IsFile(self.path)
if not self.path.exists:
raise FileError.NotFound(self.path)
def clear_cache(self) -> None:
"Clear the cached icon results"
self._cache.clear()
def get_icon(self, name: str) -> Icon:
if name in self._cache:
if (cached := self._cache[name]) is None:
raise KeyError(name)
return cached
icon = Icon(name)
for path in self.path.glob(recursive = True, ext = ["png", "svg", "svgz"]):
if name.lower() == path.stem.lower():
size_str = path.split(self.name)[1].split("/")[1]
if size_str != "scalable":
icon[int(size_str.split("x")[0])] = path
else:
icon[0] = path
if len(icon) == 0:
self._cache[name] = None
raise KeyError(name)
self._cache[name] = icon
return icon
class MemoryIconTheme(IconTheme):
__slots__ = ("name", "icons",)
def __init__(self, name: str, *icons: Icon) -> None:
"""
Create a new ``MemoryIconTheme`` object
:param name: Name of the icon theme
:param icons: List of icons to be included in the theme
"""
self.name: str = name
self.icons: dict[str, Icon] = {icon.name: icon for icon in icons}
def get_icon(self, name: str) -> Icon:
return self.icons[name]
def set_icon(self, name: str, size: int, data: bytes) -> None:
"""
Add an icon to the theme
:param name: Name of the icon
:param size: Size of the icon. Use ``0`` for scalable.
:param data: Raw data of the icon
"""
if name not in self.icons:
self.icons[name] = Icon(name)
self.icons[name][size] = data

View file

@ -4,7 +4,11 @@ import os
import shutil
import sys
from collections.abc import Iterator, Sequence
from glob import iglob
from .enums import FileType, XdgDir
from .errors import FileError
from .misc import FileSize
try:
@ -69,6 +73,11 @@ class Path(str):
return self.__class__(os.path.dirname(self))
@property
def stem(self) -> str:
return self.name.rstrip(self.ext).rstrip(".")
def join(self, *parts: str, normalize: bool = False) -> Self: # type: ignore[override]
"""
Append a path segment
@ -208,6 +217,36 @@ class File(Path):
return tuple(types)
def glob(self,
pattern: str = "**",
recursive: bool = False,
hidden: bool = False,
ext: Sequence[str] | None = None) -> Iterator[File]:
"""
Iterate through a directory with paths matching a specific pattern
.. note:: See :class:`glob.iglob` for pattern usage
:param pattern: Filename pattern to match
:param recursive: Whether or not to search through sub-directories
:param hidden: List hidden files
:param ext: Include only the specified extensions in the result if set
:raises FileError: If the path is not a directory or does not exist
"""
if self.isfile:
raise FileError.IsFile(self)
if not self.exists:
raise FileError.NotFound(self)
for path in iglob(pattern, root_dir = self, recursive = recursive, include_hidden = hidden):
filepath = self.join(path)
if ext is None or filepath.ext in ext:
yield filepath
def mkdir(self, mode: int = 0o755) -> None:
"""
Create a directory and all parent directories

22
docs/api/icon_theme.rst Normal file
View file

@ -0,0 +1,22 @@
Icon Themes
===========
.. autoclass:: blib.IconThemes
:members:
:show-inheritance:
.. autoclass:: blib.IconTheme
:members:
:show-inheritance:
.. autoclass:: blib.FileIconTheme
:members:
:show-inheritance:
.. autoclass:: blib.MemoryIconTheme
:members:
:show-inheritance:
.. autoclass:: blib.Icon
:members:
:show-inheritance:

View file

@ -5,6 +5,7 @@ subtrees:
- file: api/index.rst
subtrees:
- entries:
- file: api/icon_theme.rst
- file: api/router.rst
- file: api/enums.rst
- file: api/errors.rst