diff --git a/CHANGELOG.md b/CHANGELOG.md index bd472d8..a00a968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Next version +- Add demo gif to readme - Fix indentation of multi-line messages - Stop using dataclass (for backwards compatibility with Python 3.6) diff --git a/README.md b/README.md index 696f64f..e9b3676 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A TUI client for [euphoria.io](https://euphoria.io) +![bowl in action](demo.gif) + ## Installation Ensure that you have at least Python 3.7 installed. diff --git a/bowl/markup.py b/bowl/markup.py index a843102..ba132d5 100644 --- a/bowl/markup.py +++ b/bowl/markup.py @@ -1,30 +1,99 @@ from typing import Any, Iterable, List, Mapping, Optional, Tuple, Union -from dataclasses import dataclass -__all__ = ["Attributes", "AttributedText", "AT"] +__all__ = ["Attributes", "Chunk", "AttributedText", "AT"] Attributes = Mapping[str, Any] -@dataclass -class Char: +class Chunk: - char: str - attrs: Attributes + @staticmethod + def join_chunks(chunks: List["Chunk"]) -> List["Chunk"]: + if not chunks: + return [] - def set(self, name: str, value: Any) -> "Char": - new_attrs = dict(self.attrs) - new_attrs[name] = value - return Char(self.char, new_attrs) + new_chunks: List[Chunk] = [] - def remove(self, name: str) -> "Char": - new_attrs = dict(self.attrs) + current_chunk = chunks[0] + for chunk in chunks[1:]: + if not chunk.text: + continue + + 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 __eq__(self, other: object) -> bool: + if not isinstance(other, Chunk): + return NotImplemented + + return (self._text == other._text and + self._attributes == other._attributes) + + 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_attrs.pop(name, None) + new_attributes.pop(name, None) - return Char(self.char, new_attrs) + return Chunk(self.text, new_attributes) class AttributedText: """ @@ -33,9 +102,9 @@ class AttributedText: """ @classmethod - def from_chars(cls, chars: Iterable[Char]) -> "AttributedText": + def from_chunks(cls, chunks: Iterable[Chunk]) -> "AttributedText": new = cls() - new._chars = list(chars) + new._chunks = Chunk.join_chunks(list(chunks)) return new # Common special methods @@ -59,60 +128,117 @@ class AttributedText: attributes = dict(attributes) attributes.update(kwargs) - self._chars: List[Char] = [] - for char in text or "": - self._chars.append(Char(char, attributes)) + self._chunks: List[Chunk] = [] + if text is not None: + self._chunks.append(Chunk(text, attributes=attributes)) def __str__(self) -> str: return self.text def __repr__(self) -> str: - return "N/A" + return f"AttributedText.from_chunks({self._chunks!r})" # Uncommon special methods def __add__(self, other: "AttributedText") -> "AttributedText": - return AttributedText.from_chars(self._chars + other._chars) + return AttributedText.from_chunks(self._chunks + other._chunks) def __eq__(self, other: object) -> bool: if not isinstance(other, AttributedText): return NotImplemented - return self._chars == other._chars + return self._chunks == other._chunks def __getitem__(self, key: Union[int, slice]) -> "AttributedText": - chars: List[Char] + chunks: List[Chunk] if isinstance(key, slice): - chars = self._chars[key] + chunks = Chunk.join_chunks(self._slice(key)) else: - chars = [self._chars[key]] + chunks = [self._at(key)] - return AttributedText.from_chars(chars) + return AttributedText.from_chunks(chunks) def __len__(self) -> int: - return len(self._chars) + return sum(map(len, self._chunks)) def __mul__(self, other: int) -> "AttributedText": if not isinstance(other, int): return NotImplemented - return self.from_chars(self._chars * other) + return self.from_chunks(self.chunks * other) # Properties @property def text(self) -> str: - return "".join(char.char for char in self._chars) + return "".join(chunk.text for chunk in self._chunks) @property - def chars(self) -> List[Char]: - return list(self._chars) + 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._chars[pos].attrs + return self._at(pos).attributes def get(self, pos: int, @@ -120,7 +246,7 @@ class AttributedText: default: Any = None, ) -> Any: - return self.at(pos).get(name, default) + return self._at(pos).get(name, default) def split_by(self, attribute_name: str, @@ -128,26 +254,26 @@ class AttributedText: blocks = [] - chars: List[Char] = [] + chunks: List[Chunk] = [] attribute: Any = None - for char in self._chars: - char_attr = char.attrs.get(attribute_name) + for chunk in self._chunks: + chunk_attr = chunk.attributes.get(attribute_name) - if chars: - if attribute == char_attr: - chars.append(char) + if chunks: + if attribute == chunk_attr: + chunks.append(chunk) else: - blocks.append((self.from_chars(chars), attribute)) + blocks.append((self.from_chunks(chunks), attribute)) - chars = [char] - attribute = char_attr + chunks = [chunk] + attribute = chunk_attr else: - chars.append(char) - attribute = char_attr + chunks.append(chunk) + attribute = chunk_attr - if chars: - blocks.append((self.from_chars(chars), attribute)) + if chunks: + blocks.append((self.from_chunks(chunks), attribute)) return blocks @@ -161,11 +287,11 @@ class AttributedText: interspersed.append(self) interspersed.append(segment) - chars = [] + chunks = [] for segment in interspersed: - chars.extend(segment.chars) + chunks.extend(segment.chunks) - return self.from_chars(chars) + return self.from_chunks(chunks) def set(self, name: str, @@ -175,8 +301,8 @@ class AttributedText: ) -> "AttributedText": if start is None and stop is None: - chars = (char.set(name, value) for char in self._chars) - return AttributedText.from_chars(chars) + 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: @@ -198,8 +324,8 @@ class AttributedText: ) -> "AttributedText": if start is None and stop is None: - chars = (char.remove(name) for char in self._chars) - return AttributedText.from_chars(chars) + 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: diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..c306d8f Binary files /dev/null and b/demo.gif differ diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..6f4705b --- /dev/null +++ b/todo.txt @@ -0,0 +1,37 @@ +- config + x colors + - key bindings +- documentation (especially of the config) + +- profiling/optimisation + +- detail mode +- fold threads +- nick list +- better key bindings/controls +- center cursor on screen (after scrolling the view without scrolling the cursor) +- mouse support +- searching for messages +- better message editing when the screen is full +- detect when the dimensions are too small (meta width etc.) and display warning +- green "unread message" markers +- highlight things in messages + - offline log browsing + - @mentions + - &rooms + - https://links + - :emojis: + - /me s +- word wrapping for messages +- multi-room support +- db backend + - download room log + - auto repair gaps in log + +x robust starting script +x install via pip from github + x runnable script +x parse command-line parameters +x nick list +x room_widget refactor +x save cookies