Style dataclasses

This commit is contained in:
object-Object 2023-07-06 01:40:06 -04:00
parent 69be299199
commit 7983afde45
4 changed files with 286 additions and 186 deletions

View file

@ -85,3 +85,16 @@ def sorted_dict(d: Mapping[_T, _T_Sortable]) -> dict[_T, _T_Sortable]:
class IProperty(Protocol[_T_covariant]):
def __get__(self, __instance: Any, __owner: type | None = None, /) -> _T_covariant:
...
_K = TypeVar("_K")
_V = TypeVar("_V")
class NoClobberDict(dict[_K, _V]):
"""Dict which raises KeyError if the key being assigned to is already present."""
def __setitem__(self, key: Any, value: Any) -> None:
if key in self:
raise KeyError(f"Key {key} already exists in dict")
super().__setitem__(key, value)

View file

@ -1,8 +1,6 @@
#!/usr/bin/env python3
import io
from dataclasses import InitVar, dataclass
from html import escape
from typing import IO, Any
from typing import Any
from hexcasting import HexBook
from hexcasting.abstract_hex_pages import PageWithPattern
@ -19,6 +17,7 @@ from patchouli.page import (
SpotlightPage,
TextPage,
)
from patchouli.text.formatting import Stream
# extra info :(
# TODO: properties.toml
@ -27,94 +26,6 @@ repo_names = {
}
# TODO: type
def tag_args(kwargs: dict[str, Any]):
return "".join(
f" {'class' if key == 'clazz' else key.replace('_', '-')}={repr(escape(str(value)))}"
for key, value in kwargs.items()
)
@dataclass
class PairTag:
stream: IO[str]
name: str
args: InitVar[dict[str, Any]]
def __post_init__(self, args: dict[str, Any]):
self.args_str = tag_args(args)
def __enter__(self):
print(f"<{self.name}{self.args_str}>", file=self.stream, end="")
def __exit__(self, _1: Any, _2: Any, _3: Any):
print(f"</{self.name}>", file=self.stream, end="")
class Empty:
def __enter__(self):
pass
def __exit__(self, _1: Any, _2: Any, _3: Any):
pass
class Stream:
__slots__ = ["stream"]
def __init__(self, stream: IO[str]):
self.stream = stream
def tag(self, name: str, **kwargs: Any):
keywords = tag_args(kwargs)
print(f"<{name}{keywords} />", file=self.stream, end="")
return self
def pair_tag(self, name: str, **kwargs: Any):
return PairTag(self.stream, name, kwargs)
def pair_tag_if(self, cond: Any, name: str, **kwargs: Any):
return self.pair_tag(name, **kwargs) if cond else Empty()
def empty_pair_tag(self, name: str, **kwargs: Any):
with self.pair_tag(name, **kwargs):
pass
def text(self, txt: str | LocalizedStr):
print(escape(str(txt)), file=self.stream, end="")
return self
# TODO: move
def get_format(out: Stream, ty: str, value: Any):
if ty == "para":
return out.pair_tag("p", **value)
if ty == "color":
return out.pair_tag("span", style=f"color: #{value}")
if ty == "link":
link = value
if "://" not in link:
link = "#" + link.replace("#", "@")
return out.pair_tag("a", href=link)
if ty == "tooltip":
return out.pair_tag("span", clazz="has-tooltip", title=value)
if ty == "cmd_click":
return out.pair_tag(
"span", clazz="has-cmd_click", title="When clicked, would execute: " + value
)
if ty == "obf":
return out.pair_tag("span", clazz="obfuscated")
if ty == "bold":
return out.pair_tag("strong")
if ty == "italic":
return out.pair_tag("i")
if ty == "strikethrough":
return out.pair_tag("s")
if ty == "underline":
return out.pair_tag("span", style="text-decoration: underline")
raise ValueError("Unknown format type: " + ty)
def entry_spoilered(root_info: HexBook, entry: Entry):
if entry.advancement is None:
return False
@ -136,13 +47,7 @@ def write_block(out: Stream, block: FormatTree | str | LocalizedStr):
first = True
out.text(line)
return
sty_type = block.style.type
if sty_type == "base":
for child in block.children:
write_block(out, child)
return
tag = get_format(out, sty_type, block.style.value)
with tag:
with block.style.tag(out):
for child in block.children:
write_block(out, child)

View file

@ -1,13 +1,20 @@
# pyright: reportPrivateUsage=false
from __future__ import annotations
import contextlib
import re
from typing import NamedTuple, Self, cast
from abc import ABC, abstractmethod
from dataclasses import InitVar, dataclass as py_dataclass
from html import escape
from typing import IO, Any, Callable, ContextManager, Self, cast
from pydantic import ValidationInfo, model_validator
from pydantic.dataclasses import dataclass
from pydantic.functional_validators import ModelWrapValidatorHandler
from common.model import DEFAULT_CONFIG
from common.types import NoClobberDict
from minecraft.i18n import I18nContext, LocalizedStr
DEFAULT_MACROS = {
@ -28,8 +35,8 @@ DEFAULT_MACROS = {
"$(thing)": "$(#490)",
}
_COLORS: dict[str, str | None] = {
"0": None,
_COLORS = {
# "0": None, # TODO: find an actual value for this
"1": "00a",
"2": "0a0",
"3": "0aa",
@ -47,75 +54,254 @@ _COLORS: dict[str, str | None] = {
"f": "fff",
}
_TYPES = {
"k": "obf",
"l": "bold",
"m": "strikethrough",
"n": "underline",
"o": "italic",
}
_KEYS = {
"use": "Right Click",
"sneak": "Left Shift",
}
class Style(NamedTuple):
type: str
value: str | bool | dict[str, str] | None
# TODO: type
def tag_args(kwargs: dict[str, Any]):
return "".join(
f" {'class' if key == 'clazz' else key.replace('_', '-')}={repr(escape(str(value)))}"
for key, value in kwargs.items()
)
# TODO: make Style a dataclass, subclass for each type
def parse_style(style_text: str) -> Style | str:
if style_text in _TYPES:
return Style(_TYPES[style_text], True)
if style_text in _COLORS:
return Style("color", _COLORS[style_text])
if style_text.startswith("#") and len(style_text) in [4, 7]:
return Style("color", style_text[1:])
@py_dataclass
class PairTag:
stream: IO[str]
name: str
args: InitVar[dict[str, Any]]
# try matching the entire string
match style_text:
# replacements
def __post_init__(self, args: dict[str, Any]):
self.args_str = tag_args(args)
def __enter__(self):
# TODO: self.stream.write??????????
print(f"<{self.name}{self.args_str}>", file=self.stream, end="")
def __exit__(self, _1: Any, _2: Any, _3: Any):
print(f"</{self.name}>", file=self.stream, end="")
class Empty:
def __enter__(self):
pass
def __exit__(self, _1: Any, _2: Any, _3: Any):
pass
class Stream:
__slots__ = ["stream"]
def __init__(self, stream: IO[str]):
self.stream = stream
def tag(self, name: str, **kwargs: Any):
keywords = tag_args(kwargs)
print(f"<{name}{keywords} />", file=self.stream, end="")
return self
def pair_tag(self, name: str, **kwargs: Any):
return PairTag(self.stream, name, kwargs)
def pair_tag_if(self, cond: Any, name: str, **kwargs: Any):
return self.pair_tag(name, **kwargs) if cond else Empty()
def empty_pair_tag(self, name: str, **kwargs: Any):
with self.pair_tag(name, **kwargs):
pass
def text(self, txt: str | LocalizedStr):
print(escape(str(txt)), file=self.stream, end="")
return self
_COMMAND_LOOKUPS: list[CommandLookup] = []
_COMMANDS: dict[str, StyleCommand] = NoClobberDict()
_FUNCTIONS: dict[str, StyleFunction] = NoClobberDict()
def command_lookup(fn: CommandLookup) -> CommandLookup:
_COMMAND_LOOKUPS.append(fn)
return fn
# TODO: refactor literally all of everything, this is still pretty disgusting
@dataclass(config=DEFAULT_CONFIG)
class Style(ABC):
@classmethod
def parse(cls, style_text: str) -> Style | str:
# command lookups (includes commands and functions)
for lookup in _COMMAND_LOOKUPS:
if (style := lookup(style_text)) is not None:
return style
# oopsies
raise ValueError(f"Unhandled style: {style_text}")
@abstractmethod
def tag(self, out: Stream) -> ContextManager[None]:
...
def can_close(self, other: Style) -> bool:
return isinstance(self, type(other)) or isinstance(other, type(self))
@command_lookup
def replacement_processor(name: str):
match name:
case "br":
return "\n"
case "playername":
return "[Playername]"
# styles
case "br2":
return Style("para", {})
case "li":
return Style("para", {"clazz": "fake-li"})
case "/l":
return Style("link", None)
case "/t":
return Style("tooltip", None)
case "/c":
return Style("cmd_click", None)
case "reset" | "":
# TODO: this was "r" before, but patchouli's code has "reset"
# the tests pass either way so I don't think we're using it
return Style("base", None)
case _:
pass
return None
# command prefixes
command, rest = style_text[:2], style_text[2:]
match command:
# replacement
case "k:":
return _KEYS[rest]
# styles
case "l:":
return Style("link", rest)
case "t:":
return Style("tooltip", rest)
case "c:":
return Style("cmd_click", rest)
case _:
# TODO more style parse
raise ValueError("Unknown style: " + style_text)
@command_lookup
def command_processor(name: str):
if style_type := _COMMANDS.get(name):
return style_type()
@command_lookup
def function_processor(style_text: str):
if ":" in style_text:
name, param = style_text.split(":", 1)
if style_type := _FUNCTIONS.get(name):
return style_type(param)
raise ValueError(f"Unhandled function: {style_text}")
@dataclass(config=DEFAULT_CONFIG)
class BaseStyleCommand(Style):
def __init_subclass__(cls, names: list[str]) -> None:
for name in names:
_COMMANDS[name] = cls
@dataclass(config=DEFAULT_CONFIG)
class EndFunctionStyle(BaseStyleCommand, names=[]):
function_type: type[BaseStyleFunction]
def tag(self, out: Stream):
return contextlib.nullcontext()
def can_close(self, other: Style) -> bool:
return super().can_close(other) or isinstance(other, self.function_type)
@dataclass(config=DEFAULT_CONFIG)
class BaseStyleFunction(Style):
value: str | None
def __init_subclass__(cls, names: list[str]) -> None:
for name in names:
_FUNCTIONS[name] = cls
_COMMANDS[f"/{name}"] = lambda: EndFunctionStyle(cls)
CommandLookup = Callable[[str], Style | str | None]
StyleCommand = Callable[[], Style | str]
StyleFunction = Callable[[str], BaseStyleFunction | str]
def style_function(name: str):
def wrap(fn: StyleFunction) -> StyleFunction:
_FUNCTIONS[name] = fn
return fn
return wrap
class ClearStyle(BaseStyleCommand, names=[""]):
def tag(self, out: Stream):
return contextlib.nullcontext()
class ParagraphStyle(BaseStyleCommand, names=["br2"]):
def tag(self, out: Stream):
return out.pair_tag("p")
class ListItemStyle(ParagraphStyle, names=["li"]):
def tag(self, out: Stream):
return out.pair_tag("p", clazz="fake-li")
class ObfuscatedStyle(BaseStyleCommand, names=["k"]):
def tag(self, out: Stream):
return out.pair_tag("span", clazz="obfuscated")
class BoldStyle(BaseStyleCommand, names=["l"]):
def tag(self, out: Stream):
return out.pair_tag("strong")
class StrikethroughStyle(BaseStyleCommand, names=["m"]):
def tag(self, out: Stream):
return out.pair_tag("s")
class UnderlineStyle(BaseStyleCommand, names=["n"]):
def tag(self, out: Stream):
return out.pair_tag("span", style="text-decoration: underline")
class ItalicStyle(BaseStyleCommand, names=["o"]):
def tag(self, out: Stream):
return out.pair_tag("i")
@style_function("k")
def get_keybind_key(param: str):
if (key := _KEYS.get(param)) is not None:
return key
raise ValueError(f"Unhandled key: {param}")
# TODO: this should use Color but i'm pretty sure that will fail the snapshots
class ColorStyle(BaseStyleFunction, names=[]):
@command_lookup
@staticmethod
def processor(param: str):
if param in _COLORS:
return ColorStyle(_COLORS[param])
if param.startswith("#") and len(param) in [4, 7]:
return ColorStyle(param[1:])
def tag(self, out: Stream):
return out.pair_tag("span", style=f"color: #{self.value}")
class LinkStyle(BaseStyleFunction, names=["l"]):
def tag(self, out: Stream):
href = self.value
if href is not None and not href.startswith(("http:", "https:")):
href = "#" + href.replace("#", "@")
return out.pair_tag("a", href=href)
class TooltipStyle(BaseStyleFunction, names=["t"]):
def tag(self, out: Stream):
return out.pair_tag("span", clazz="has-tooltip", title=self.value)
class CmdClickStyle(BaseStyleFunction, names=["c"]):
def tag(self, out: Stream):
return out.pair_tag(
"span",
clazz="has-cmd_click",
title=f"When clicked, would execute: {self.value}",
)
# class GangnamStyle: pass
_FORMAT_RE = re.compile(r"\$\(([^)]*)\)")
@ -132,7 +318,7 @@ class FormatTree:
@classmethod
def empty(cls) -> Self:
return cls(Style("base", None), [])
return cls(ClearStyle(), [])
@classmethod
def format(cls, string: str, macros: dict[str, str]) -> Self:
@ -156,7 +342,7 @@ class FormatTree:
text_since_prev_style.append(leading_text)
last_end = match.end()
match parse_style(match[1]):
match Style.parse(match[1]):
case str(replacement):
# str means "use this instead of the original value"
text_since_prev_style.append(replacement)
@ -171,29 +357,32 @@ class FormatTree:
# parse
style_stack = [
FormatTree(Style("base", True), []),
FormatTree(Style("para", {}), [first_node]),
FormatTree(ClearStyle(), []),
FormatTree(ParagraphStyle(), [first_node]),
]
for style, text in zip(styles, text_nodes):
tmp_stylestack: list[Style] = []
if style.type == "base":
while style_stack[-1].style.type != "para":
if isinstance(style, ClearStyle):
while not isinstance(style_stack[-1].style, ParagraphStyle):
last_node = style_stack.pop()
style_stack[-1].children.append(last_node)
elif any(tree.style.type == style.type for tree in style_stack):
elif any(style.can_close(tree.style) for tree in style_stack):
while len(style_stack) >= 2:
last_node = style_stack.pop()
style_stack[-1].children.append(last_node)
if last_node.style.type == style.type:
if style.can_close(last_node.style):
break
tmp_stylestack.append(last_node.style)
for sty in tmp_stylestack:
style_stack.append(FormatTree(sty, []))
if style.value is None:
if isinstance(style, (EndFunctionStyle, ClearStyle)):
if text:
style_stack[-1].children.append(text)
else:
style_stack.append(FormatTree(style, [text] if text else []))
while len(style_stack) >= 2:
last_node = style_stack.pop()
style_stack[-1].children.append(last_node)

View file

@ -1,5 +1,6 @@
# pyright: reportPrivateUsage=false
from patchouli.text import DEFAULT_MACROS, FormatTree, Style
from patchouli.text import DEFAULT_MACROS, FormatTree
from patchouli.text.formatting import ClearStyle, ColorStyle, LinkStyle, ParagraphStyle
def test_format_string():
@ -12,65 +13,57 @@ def test_format_string():
# assert
# TODO: possibly make this less lazy
assert tree == FormatTree(
style=Style(type="base", value=True),
style=ClearStyle(),
children=[
FormatTree(
style=Style(type="para", value={}),
style=ParagraphStyle(),
children=[
"Write the given iota to my ",
FormatTree(
style=Style(
type="link",
value="patterns/readwrite#hexcasting:write/local",
),
style=LinkStyle("patterns/readwrite#hexcasting:write/local"),
children=[
FormatTree(
style=Style(type="color", value="490"),
style=ColorStyle("490"),
children=["local"],
)
],
),
".\nThe ",
FormatTree(
style=Style(
type="link",
value="patterns/readwrite#hexcasting:write/local",
),
style=LinkStyle("patterns/readwrite#hexcasting:write/local"),
children=[
FormatTree(
style=Style(type="color", value="490"),
style=ColorStyle("490"),
children=["local"],
)
],
),
" is a lot like a ",
FormatTree(
style=Style(type="link", value="items/focus"),
style=LinkStyle("items/focus"),
children=[
FormatTree(
style=Style(type="color", value="b0b"),
style=ColorStyle("b0b"),
children=["Focus"],
)
],
),
". It's cleared when I stop casting a Hex, starts with ",
FormatTree(
style=Style(type="link", value="casting/influences"),
style=LinkStyle("casting/influences"),
children=[
FormatTree(
style=Style(type="color", value="490"),
style=ColorStyle("490"),
children=["Null"],
)
],
),
" in it, and is preserved between casts of ",
FormatTree(
style=Style(
type="link", value="patterns/meta#hexcasting:for_each"
),
style=LinkStyle("patterns/meta#hexcasting:for_each"),
children=[
FormatTree(
style=Style(type="color", value="fc77be"),
style=ColorStyle("fc77be"),
children=["Thoth's Gambit"],
)
],