bowl/cheuph/markup.py

288 lines
8.2 KiB
Python

from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
__all__ = ["Attributes", "Chunk", "AttributedText", "AT"]
Attributes = Dict[str, Any]
# TODO remove empty Chunks in join_chunks
class Chunk:
@staticmethod
def join_chunks(chunks: List["Chunk"]) -> List["Chunk"]:
if not chunks:
return []
new_chunks: List[Chunk] = []
current_chunk = chunks[0]
for chunk in chunks[1:]:
joined_chunk = current_chunk._join(chunk)
if joined_chunk is None:
new_chunks.append(current_chunk)
current_chunk = chunk
else:
current_chunk = joined_chunk
new_chunks.append(current_chunk)
return new_chunks
# Common special methods
def __init__(self,
text: str,
attributes: Attributes = {},
) -> None:
self._text = text
self._attributes = dict(attributes)
def __str__(self) -> str:
return self.text
def __repr__(self) -> str:
return f"Chunk({self.text!r}, {self._attributes!r})"
# Uncommon special methods
def __getitem__(self, key: Union[int, slice]) -> "Chunk":
return Chunk(self.text[key], self._attributes)
def __len__(self) -> int:
return len(self.text)
# Properties
@property
def text(self) -> str:
return self._text
@property
def attributes(self) -> Attributes:
return dict(self._attributes)
# Private methods
def _join(self, chunk: "Chunk") -> Optional["Chunk"]:
if self._attributes == chunk._attributes:
return Chunk(self.text + chunk.text, self._attributes)
return None
# Public methods
def get(self, name: str, default: Any = None) -> Any:
return self.attributes.get(name, default)
def set(self, name: str, value: Any) -> "Chunk":
new_attributes = dict(self._attributes)
new_attributes[name] = value
return Chunk(self.text, new_attributes)
def remove(self, name: str) -> "Chunk":
new_attributes = dict(self._attributes)
# This removes the value with that key, if it exists, and does nothing
# if it doesn't exist. (Since we give a default value, no KeyError is
# raised if the key isn't found.)
new_attributes.pop(name, None)
return Chunk(self.text, new_attributes)
class AttributedText:
"""
Objects of this class are immutable and behave str-like. Supported
operations are len, + and splicing.
"""
@classmethod
def from_chunks(cls, chunks: Iterable[Chunk]) -> "AttributedText":
new = cls()
new._chunks = Chunk.join_chunks(list(chunks))
return new
# Common special methods
def __init__(self, text: Optional[str] = None) -> None:
self._chunks: List[Chunk] = []
if text is not None:
self._chunks.append(Chunk(text))
def __str__(self) -> str:
return self.text
def __repr__(self) -> str:
return f"AttributedText.from_chunks({self._chunks!r})"
# Uncommon special methods
def __add__(self, other: "AttributedText") -> "AttributedText":
return AttributedText.from_chunks(self._chunks + other._chunks)
def __getitem__(self, key: Union[int, slice]) -> "AttributedText":
chunks: List[Chunk]
if isinstance(key, slice):
chunks = Chunk.join_chunks(self._slice(key))
else:
chunks = [self._at(key)]
return AttributedText.from_chunks(chunks)
def __len__(self) -> int:
return sum(map(len, self._chunks))
# Properties
@property
def text(self) -> str:
return "".join(chunk.text for chunk in self._chunks)
@property
def chunks(self) -> List[Chunk]:
return list(self._chunks)
# Private methods
def _at(self, key: int) -> Chunk:
if key < 0:
key = len(self) + key
pos = 0
for chunk in self._chunks:
chunk_key = key - pos
if 0 <= chunk_key < len(chunk):
return chunk[chunk_key]
pos += len(chunk)
# We haven't found the chunk
raise KeyError
def _slice(self, key: slice) -> List[Chunk]:
start, stop, step = key.start, key.stop, key.step
if start is None:
start = 0
elif start < 0:
start = len(self) + start
if stop is None:
stop = len(self)
elif stop < 0:
stop = len(self) + stop
pos = 0 # cursor position
resulting_chunks = []
for chunk in self._chunks:
chunk_start = start - pos
chunk_stop = stop - pos
offset: Optional[int] = None
if step is not None:
offset = (start - pos) % step
if chunk_stop <= 0 or chunk_start >= len(chunk):
pass
elif chunk_start < 0 and chunk_stop > len(chunk):
resulting_chunks.append(chunk[offset::step])
elif chunk_start < 0:
resulting_chunks.append(chunk[offset:chunk_stop:step])
elif chunk_stop > len(chunk):
resulting_chunks.append(chunk[chunk_start::step])
else:
resulting_chunks.append(chunk[chunk_start:chunk_stop:step])
pos += len(chunk)
return resulting_chunks
# Public methods
def at(self, pos: int) -> Attributes:
return self._at(pos).attributes
def get(self,
pos: int,
name: str,
default: Any = None,
) -> Any:
return self._at(pos).get(name, default)
# "find all separate blocks with this property"
def find_all(self, name: str) -> List[Tuple[Any, int, int]]:
blocks = []
pos = 0
block = None
for chunk in self._chunks:
if name in chunk.attributes:
attribute = chunk.attributes[name]
start = pos
stop = pos + len(chunk)
if block is None:
block = (attribute, start, stop)
continue
block_attr, block_start, _ = block
if block_attr == attribute:
block = (attribute, block_start, stop)
else:
blocks.append(block)
block = (attribute, start, stop)
pos += len(chunk)
if block is not None:
blocks.append(block)
return blocks
def set(self,
name: str,
value: Any,
start: Optional[int] = None,
stop: Optional[int] = None,
) -> "AttributedText":
if start is None and stop is None:
chunks = (chunk.set(name, value) for chunk in self._chunks)
return AttributedText.from_chunks(chunks)
elif start is None:
return self[:stop].set(name, value) + self[stop:]
elif stop is None:
return self[:start] + self[start:].set(name, value)
elif start > stop:
# set value everywhere BUT the specified interval
return self.set(name, value, stop=stop).set(name, value, start=start)
else:
middle = self[start:stop].set(name, value)
return self[:start] + middle + self[stop:]
def set_at(self, name: str, value: Any, pos: int) -> "AttributedText":
return self.set(name, value, pos, pos)
def remove(self,
name: str,
start: Optional[int] = None,
stop: Optional[int] = None,
) -> "AttributedText":
if start is None and stop is None:
chunks = (chunk.remove(name) for chunk in self._chunks)
return AttributedText.from_chunks(chunks)
elif start is None:
return self[:stop].remove(name) + self[stop:]
elif stop is None:
return self[:start] + self[start:].remove(name)
elif start > stop:
# remove value everywhere BUT the specified interval
return self.remove(name, stop=stop).remove(name, start=start)
else:
middle = self[start:stop].remove(name)
return self[:start] + middle + self[stop:]
AT = AttributedText