first
This commit is contained in:
commit
9c5abb66c5
5 changed files with 628 additions and 0 deletions
77
flake.lock
Normal file
77
flake.lock
Normal file
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694529238,
|
||||
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"make-shell": {
|
||||
"locked": {
|
||||
"lastModified": 1634940815,
|
||||
"narHash": "sha256-P69OmveboXzS+es1vQGS4bt+ckwbeIExqxfGLjGuJqA=",
|
||||
"owner": "ursi",
|
||||
"repo": "nix-make-shell",
|
||||
"rev": "8add91681170924e4d0591b22f294aee3f5516f9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ursi",
|
||||
"repo": "nix-make-shell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1698318101,
|
||||
"narHash": "sha256-gUihHt3yPD7bVqg+k/UVHgngyaJ3DMEBchbymBMvK1E=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "63678e9f3d3afecfeafa0acead6239cdb447574c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"make-shell": "make-shell",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
51
flake.nix
Normal file
51
flake.nix
Normal file
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
description = "pyansi";
|
||||
inputs = {
|
||||
nixpkgs = {
|
||||
url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
};
|
||||
flake-utils = {
|
||||
url = "github:numtide/flake-utils";
|
||||
};
|
||||
make-shell = {
|
||||
url = "github:ursi/nix-make-shell";
|
||||
};
|
||||
};
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
make-shell,
|
||||
} @ inputs:
|
||||
flake-utils.lib.eachDefaultSystem
|
||||
(
|
||||
system: let
|
||||
version = self.lastModifiedDate;
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
make-shell = import inputs.make-shell {
|
||||
inherit system pkgs;
|
||||
};
|
||||
in {
|
||||
devShells.default = let
|
||||
python = pkgs.python312.withPackages (
|
||||
ps:
|
||||
with ps; [
|
||||
# poetry-core
|
||||
]
|
||||
);
|
||||
project = "pyansi";
|
||||
in
|
||||
make-shell {
|
||||
packages = [
|
||||
python
|
||||
pkgs.poetry
|
||||
];
|
||||
env = {
|
||||
NIX_PROJECT = project;
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
7
poetry.lock
generated
Normal file
7
poetry.lock
generated
Normal file
|
@ -0,0 +1,7 @@
|
|||
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
|
||||
package = []
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "34e39677d8527182346093002688d17a5d2fc204b9eb3e094b2e6ac519028228"
|
467
pyansi/__init__.py
Normal file
467
pyansi/__init__.py
Normal file
|
@ -0,0 +1,467 @@
|
|||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from enum import IntEnum
|
||||
from enum import StrEnum
|
||||
from enum import auto
|
||||
from functools import wraps
|
||||
from typing import Annotated
|
||||
from typing import Callable
|
||||
from typing import Self
|
||||
from typing import get_args
|
||||
from typing import get_origin
|
||||
from typing import get_type_hints
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValueRange:
|
||||
low: int
|
||||
high: int
|
||||
|
||||
|
||||
def check_annotations(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
def wrapped(*args, **kwargs):
|
||||
type_hints = get_type_hints(func, include_extras=True)
|
||||
for param, hint in type_hints.items():
|
||||
if get_origin(hint) is not Annotated:
|
||||
continue
|
||||
hint_type, *hint_args = get_args(hint)
|
||||
if hint_type is list or get_origin(hint_type) is list:
|
||||
for arg in hint_args:
|
||||
if isinstance(arg, ValueRange):
|
||||
value = kwargs[param]
|
||||
if value < arg.low or value > arg.high:
|
||||
raise ValueError(
|
||||
f"Parameter '{param}' must be >= {arg.low} and <= {arg.high}"
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def readline_wrap(func: Callable) -> Callable:
|
||||
"""wrap output in SOH/STX so that readline handles prompts properly"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapped(ansi: ANSI, *args, **kwargs):
|
||||
result = ""
|
||||
if ansi.readline_wrap:
|
||||
result += ansi.SOH
|
||||
result += func(ansi, *args, **kwargs)
|
||||
if ansi.readline_wrap:
|
||||
result += ansi.STX
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class Mode(Enum):
|
||||
C0 = auto()
|
||||
C1 = auto()
|
||||
Bash = auto()
|
||||
|
||||
|
||||
class Reset:
|
||||
"""reset all graphics renditions to the default"""
|
||||
|
||||
def __new__(cls: type[Self]) -> Self:
|
||||
if not hasattr(cls, "instance"):
|
||||
cls.instance = super(Reset, cls).__new__(cls)
|
||||
return cls.instance
|
||||
|
||||
|
||||
class Color8(IntEnum):
|
||||
"""8 color palette"""
|
||||
|
||||
BLACK = 0
|
||||
RED = 1
|
||||
GREEN = 2
|
||||
YELLOW = 3
|
||||
BLUE = 4
|
||||
MAGENTA = 5
|
||||
CYAN = 6
|
||||
WHITE = 7
|
||||
|
||||
|
||||
class Style(IntEnum):
|
||||
"""enable graphics rendition style"""
|
||||
|
||||
BOLD = 1
|
||||
DIM = 2
|
||||
FAINT = 2
|
||||
ITALIC = 3
|
||||
UNDERLINE = 4
|
||||
BLINKING = 5
|
||||
INVERSE = 7
|
||||
REVERSE = 7
|
||||
HIDDEN = 8
|
||||
INVISIBLE = 8
|
||||
STRIKETHROUGH = 9
|
||||
DOUBLE_UNDERLINE = 21
|
||||
|
||||
|
||||
class ResetStyle(IntEnum):
|
||||
"""reset graphics rendition style"""
|
||||
|
||||
BOLD = 22
|
||||
DIM = 22
|
||||
FAINT = 22
|
||||
ITALIC = 23
|
||||
UNDERLINE = 24
|
||||
BLINKING = 25
|
||||
INVERSE = 27
|
||||
REVERSE = 27
|
||||
HIDDEN = 28
|
||||
INVISIBLE = 28
|
||||
STRIKETHROUGH = 29
|
||||
DOUBLE_UNDERLINE = 24
|
||||
|
||||
|
||||
class UnderlineStyle(IntEnum):
|
||||
NO_UNDERLINE = 0
|
||||
STRAIGHT_UNDERLINE = 1
|
||||
DOUBLE_UNDERLINE = 2
|
||||
CURLY_UNDERLINE = 3
|
||||
DOTTED_UNDERLINE = 4
|
||||
DASHED_UNDERLINE = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class Color256:
|
||||
color: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ColorRGB:
|
||||
red: int
|
||||
green: int
|
||||
blue: int
|
||||
|
||||
|
||||
class Layer(Enum):
|
||||
FOREGROUND = auto()
|
||||
BACKGROUND = auto()
|
||||
UNDERLINE = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Color:
|
||||
color: Color8 | Color256 | ColorRGB
|
||||
layer: Layer = Layer.FOREGROUND
|
||||
bright: bool = False
|
||||
|
||||
|
||||
GraphicsRendtion = Reset | Style | ResetStyle | UnderlineStyle | Color
|
||||
|
||||
|
||||
class EraseDisplayMode(IntEnum):
|
||||
TO_END_OF_SCREEN = 0
|
||||
TO_CURSOR = 1
|
||||
ENTIRE_SCREEN = 2
|
||||
SAVED_LINES = 3
|
||||
|
||||
|
||||
class EraseLineMode(IntEnum):
|
||||
TO_END_OF_LINE = 0
|
||||
TO_CURSOR = 1
|
||||
ENTIRE_LINE = 2
|
||||
|
||||
|
||||
class ActiveStatusDisplayMode(IntEnum):
|
||||
MAIN_DISPLAY = 0
|
||||
STATUS_LINE = 1
|
||||
|
||||
|
||||
class StatusLineType(IntEnum):
|
||||
NONE = 0
|
||||
INIDICATOR = 1
|
||||
HOST_WRITABLE = 2
|
||||
|
||||
|
||||
class DesktopNotificationPart(StrEnum):
|
||||
TITLE = "title"
|
||||
BODY = "body"
|
||||
|
||||
|
||||
class DesktopNotificationDone(IntEnum):
|
||||
NOT_DONE = 0
|
||||
DONE = 1
|
||||
|
||||
|
||||
class ANSI:
|
||||
mode: Mode
|
||||
wrap: bool
|
||||
|
||||
def __init__(self: Self, mode: Mode, wrap: bool = False) -> None:
|
||||
self.mode = mode
|
||||
self.wrap = wrap
|
||||
|
||||
@property
|
||||
def SOH(self: Self) -> str:
|
||||
match self.mode:
|
||||
case Mode.C0 | Mode.C1:
|
||||
return "\x01"
|
||||
case Mode.Bash:
|
||||
return "\\["
|
||||
|
||||
@property
|
||||
def STX(self: Self) -> str:
|
||||
match self.mode:
|
||||
case Mode.C0 | Mode.C1:
|
||||
return "\x02"
|
||||
case Mode.Bash:
|
||||
return "\\]"
|
||||
|
||||
def _wrap(self: Self, data: str) -> str:
|
||||
if self.wrap:
|
||||
return f"{self.SOH}{data}{self.STX}"
|
||||
return data
|
||||
|
||||
@property
|
||||
def RL_PROMPT_START_IGNORE(self: Self) -> str:
|
||||
if self.wrap:
|
||||
return self.SOH
|
||||
return ""
|
||||
|
||||
@property
|
||||
def RL_PROMPT_END_IGNORE(self: Self) -> str:
|
||||
if self.wrap:
|
||||
return self.STX
|
||||
return ""
|
||||
|
||||
@property
|
||||
def BEL(self: Self) -> str:
|
||||
match self.mode:
|
||||
case Mode.C0 | Mode.C1:
|
||||
return "\x07"
|
||||
case Mode.Bash:
|
||||
return "\\a"
|
||||
|
||||
BS = "\x08"
|
||||
HT = "\x09"
|
||||
LF = "\x0a"
|
||||
VT = "\x0b"
|
||||
FF = "\x0c"
|
||||
CR = "\x0d"
|
||||
|
||||
@property
|
||||
def ESC(self) -> str:
|
||||
"""Escape"""
|
||||
match self.mode:
|
||||
case Mode.C0 | Mode.C1:
|
||||
return "\x1b"
|
||||
case Mode.Bash:
|
||||
return "\\e"
|
||||
|
||||
DEL = "\x7f"
|
||||
|
||||
@property
|
||||
def DCS(self: Self) -> str:
|
||||
"""Device Control String"""
|
||||
match self.mode:
|
||||
case Mode.C0 | Mode.Bash:
|
||||
return f"{self.ESC}P"
|
||||
case Mode.C1:
|
||||
return "\x8d"
|
||||
|
||||
@property
|
||||
def CSI(self) -> str:
|
||||
"""Control Sequence Introducer"""
|
||||
match self.mode:
|
||||
case Mode.C0 | Mode.Bash:
|
||||
return f"{self.ESC}["
|
||||
case Mode.C1:
|
||||
return "\x9b"
|
||||
|
||||
@property
|
||||
def ST(self) -> str:
|
||||
match self.mode:
|
||||
case Mode.C0:
|
||||
return f"{self.ESC}\\"
|
||||
case Mode.C1:
|
||||
return "\x9c"
|
||||
case Mode.Bash:
|
||||
return self.BEL
|
||||
|
||||
@property
|
||||
def OSC(self: Self) -> str:
|
||||
"""Operating System Command"""
|
||||
match self.mode:
|
||||
case Mode.C0 | Mode.Bash:
|
||||
return f"{self.ESC}]"
|
||||
case Mode.C1:
|
||||
return "\x9d"
|
||||
|
||||
@readline_wrap
|
||||
def GraphicsRendition(self: Self, *args: GraphicsRendtion) -> str:
|
||||
result = self.CSI
|
||||
for index, g in enumerate(args):
|
||||
if index > 0:
|
||||
result += ";"
|
||||
match g:
|
||||
case Reset():
|
||||
result += "0"
|
||||
|
||||
case Style():
|
||||
result += "{g}"
|
||||
|
||||
case ResetStyle():
|
||||
result += "{g}"
|
||||
|
||||
case UnderlineStyle():
|
||||
result += "4:{g}"
|
||||
|
||||
case Color():
|
||||
match g.color:
|
||||
case Color8():
|
||||
match g.layer:
|
||||
case Layer.FOREGROUND:
|
||||
if g.bright:
|
||||
offset = 90
|
||||
else:
|
||||
offset = 30
|
||||
case Layer.BACKGROUND:
|
||||
if g.bright:
|
||||
offset = 100
|
||||
else:
|
||||
offset = 40
|
||||
case Layer.UNDERLINE:
|
||||
offset = 50
|
||||
result += f"{g.color + offset}"
|
||||
|
||||
case Color256():
|
||||
match g.layer:
|
||||
case Layer.FOREGROUND:
|
||||
code = 38
|
||||
case Layer.BACKGROUND:
|
||||
code = 48
|
||||
case Layer.UNDERLINE:
|
||||
code = 58
|
||||
result += f"{code};5;{g.color.color}"
|
||||
|
||||
case ColorRGB():
|
||||
match g.layer:
|
||||
case Layer.FOREGROUND:
|
||||
code = 38
|
||||
case Layer.BACKGROUND:
|
||||
code = 48
|
||||
case Layer.UNDERLINE:
|
||||
code = 58
|
||||
result += (
|
||||
f"{code};2;{g.color.red};{g.color.green};{g.color.blue}"
|
||||
)
|
||||
|
||||
result += "m"
|
||||
return result
|
||||
|
||||
def CursorUp(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}A"
|
||||
|
||||
def CursorDown(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}B"
|
||||
|
||||
def CursorForward(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}C"
|
||||
|
||||
def CursorBack(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}D"
|
||||
|
||||
def CursorNextLine(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}E"
|
||||
|
||||
def CursorPreviousLine(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}F"
|
||||
|
||||
def CursorHorizontalAbsolute(self: Self, n: int, alternate: bool = False) -> str:
|
||||
if alternate:
|
||||
return f"{self.CSI}{n}f"
|
||||
return f"{self.CSI}{n}G"
|
||||
|
||||
def CursorPosition(self: Self, n: int, m: int, alternate: bool = False) -> str:
|
||||
if alternate:
|
||||
return f"{self.CSI}{n};{m}f"
|
||||
return f"{self.CSI}{n};{m}H"
|
||||
|
||||
def EraseInDisplay(self: Self, mode: EraseDisplayMode) -> str:
|
||||
return f"{self.CSI}{mode}J"
|
||||
|
||||
def EraseInLine(self: Self, mode: EraseLineMode) -> str:
|
||||
return f"{self.CSI}{mode}K"
|
||||
|
||||
def ScrollUp(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}S"
|
||||
|
||||
def ScrollDown(self: Self, n: int) -> str:
|
||||
return f"{self.CSI}{n}T"
|
||||
|
||||
@property
|
||||
def SaveCursorPosition(self: Self) -> str:
|
||||
return f"{self.CSI}s"
|
||||
|
||||
@property
|
||||
def RestoreCursorPosition(self: Self) -> str:
|
||||
return f"{self.CSI}u"
|
||||
|
||||
@readline_wrap
|
||||
def Title(
|
||||
self: Self, text: str, icon_name: bool = True, window_title: bool = True
|
||||
) -> str:
|
||||
code = 0
|
||||
match (icon_name, window_title):
|
||||
case (False, False):
|
||||
raise ValueError("icon_name and window_tile can't both be false")
|
||||
case (False, True):
|
||||
code = 2
|
||||
case (True, False):
|
||||
code = 1
|
||||
case (True, True):
|
||||
code = 0
|
||||
return f"{self.OSC}{code};{text}{self.ST}"
|
||||
|
||||
def Hyperlink(self: Self, link: str, text: str) -> str:
|
||||
return (
|
||||
self._wrap(f"{self.OSC}8;;{link}{self.ST}")
|
||||
+ text
|
||||
+ self._wrap(f"{self.OSC}8;;{self.ST}")
|
||||
)
|
||||
|
||||
@readline_wrap
|
||||
def Notification(self: Self, text: str) -> str:
|
||||
return f"{self.OSC}9;{text}{self.ST}"
|
||||
|
||||
@readline_wrap
|
||||
def CurrentDirectory(self: Self, text: str) -> str:
|
||||
return f"{self.OSC}1337;CurrentDir={text}{self.ST}"
|
||||
|
||||
@readline_wrap
|
||||
def ActiveStatusDisplay(self: Self, mode: ActiveStatusDisplayMode) -> str:
|
||||
return f"{self.CSI}{mode}$}}"
|
||||
|
||||
@readline_wrap
|
||||
def StatusLineType(self: Self, mode: StatusLineType) -> str:
|
||||
return f"{self.CSI}{mode}$~"
|
||||
|
||||
@readline_wrap
|
||||
def DesktopNotification(self: Self, identifier: str, title: str, body: str) -> str:
|
||||
def format(
|
||||
part: DesktopNotificationPart, text: str, done: DesktopNotificationDone
|
||||
) -> str:
|
||||
return f"{self.OSC}99;i={identifier};e=0;p={part};d={done};{text}{self.ST}"
|
||||
|
||||
result = ""
|
||||
stride = 256
|
||||
|
||||
for part in [title[i : i + stride] for i in range(0, len(title), stride)]:
|
||||
done = DesktopNotificationDone.NOT_DONE
|
||||
if len(part) < stride and len(body) == 0:
|
||||
done = DesktopNotificationDone.DONE
|
||||
|
||||
result += format(DesktopNotificationPart.TITLE, part, done)
|
||||
|
||||
for part in [body[i : i + stride] for i in range(0, len(body), stride)]:
|
||||
done = DesktopNotificationDone.NOT_DONE
|
||||
if len(part) < stride:
|
||||
done = DesktopNotificationDone.DONE
|
||||
|
||||
result += format(DesktopNotificationPart.TITLE, part, done)
|
||||
|
||||
return result
|
26
pyproject.toml
Normal file
26
pyproject.toml
Normal file
|
@ -0,0 +1,26 @@
|
|||
[tool.poetry]
|
||||
name = "pyansi"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Jeffrey C. Ollie <jeff@ocjtech.us>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
|
||||
[tool.black]
|
||||
max-line-length = 120
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 120
|
||||
force_single_line = true
|
||||
from_first = false
|
||||
force_sort_within_sections = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
Loading…
Reference in a new issue