Source code for functui.rich_text

from functools import reduce, partial, lru_cache, cache
from enum import Enum, auto
from typing import NamedTuple, Iterable, Self, Callable
from dataclasses import dataclass
from itertools import chain
import re
import math


from .classes import *


# This is a mess, but if it works, dont fix it.

[docs] class Justify(Enum): """Text Justification. Attributes: LEFT: CENTER: RIGHT: """ LEFT = auto() CENTER = auto() RIGHT = auto()
[docs] @dataclass(frozen=True) class Span: text: tuple[str | Self, ...] rule: StyleRule
[docs] class Segment(NamedTuple): text: str rule: StyleRule length: int
[docs] @dataclass(frozen=True, eq=True) class Group: """Represents a connected span of text, like a word or a space.""" segments: tuple[Segment, ...] is_space: bool @property def length(self): return sum(i.length for i in self.segments) def extend(self, seg: Segment): return self.__class__((*self.segments, seg), self.is_space) def split(self, max_width: int, measure_text: MeasureTextFunc) -> list[Self]: total_length = 0 allowed_segments = [] overflowing_segments = list(self.segments) for segment in self.segments: if total_length + segment.length <= max_width: total_length += segment.length allowed_segments.append(segment) del overflowing_segments[0] continue # handle overflowing segmend del overflowing_segments[0] allowed_letters = [] overflowing_letters = list(segment.text) total_letter_length = 0 for i, letter in enumerate(segment.text): letter_len = measure_text(letter) if total_length + total_letter_length + letter_len <= max_width: allowed_letters.append(letter) del overflowing_letters[0] total_letter_length += letter_len continue if allowed_letters: allowed_segments.append( Segment("".join(allowed_letters), segment.rule, total_letter_length) ) overflowing_segments.insert( 0, Segment( "".join(overflowing_letters), segment.rule, segment.length - total_letter_length ) ) break if not overflowing_segments: return [self] return [ self.__class__(tuple(allowed_segments), self.is_space), self.__class__(tuple(overflowing_segments), self.is_space) ]
TextWrapFunc = Callable[[Iterable[Segment], int, MeasureTextFunc], Iterable[Iterable[Segment]]] """takes in a line. If line is too long it will be wrapped. New line characters have no effect on the outcome"""
[docs] def rich_text(*string: Span | str): """A data node for text that can be styled. Args: *string: The text content. Parts of content can be wrapped in a :obj:`span` for styling. """ span = Span(string, rule=StyleRule()) def min_size(measure_text, available: Rect): groups = tuple(tuple(i) for i in _span_to_lines(span, measure_text)) return Rect( max((sum(group.length for group in line) for line in groups), default=0), len(groups) ) return Layout( func=rich_text, min_size=min_size, render=partial(_rich_text_render, span), )
@lru_cache(LRU_MAX_SIZE) def _rich_text_render(span: Span, frame: Frame, box: Box): if box.width <= 4: return Result() lines = _span_to_lines(span, frame.measure_text) res = Result() for dy, line in enumerate(lines): if dy == box.height: break dx = 0 for segment in chain.from_iterable(g.segments for g in line): res.draw_string_line( frame.with_style(frame.default_style.apply_rule(segment.rule)), segment.text, box.position + Coordinate(dx, dy) ) dx += frame.measure_text(segment.text) return res
[docs] def adaptive_text(*string: Span | str, justify=Justify.LEFT, soft_hyphen: str = "-"): """A data node for text that can be wrapped and styled. Args: *string: The text content. Parts of content can be wrapped in a :obj:`span` for styling. justify: Text justification. soft_hyphen: Display to signal a word being wrapped between to line. """ span = Span(string, rule=StyleRule()) def min_size(measure_text, available: Rect): groups = tuple(tuple(i) for i in _span_to_lines(span, measure_text)) # longest_word = max(i.length for i in chain.from_iterable((l for l in groups))) lines = list( chain.from_iterable( wrap_line_default(line, available.width, measure_text, soft_hyphen) for line in groups ) ) ret = Rect( max((sum(group.length for group in line) for line in lines), default=0), len(lines) ) return ret return Layout( func=adaptive_text, min_size=min_size, render=partial(_adaptive_text_render, span, justify, soft_hyphen), )
@lru_cache(LRU_MAX_SIZE) def _adaptive_text_render(span: Span, justify: Justify, soft_hyphen: str, frame: Frame, box: Box): if box.width <= 1: return Result() groups = _span_to_lines(span, frame.measure_text) res = Result() lines = list( chain.from_iterable( wrap_line_default(line, box.width, frame.measure_text, soft_hyphen) for line in groups ) ) for dy, line in enumerate(lines): if dy == box.height: break dx = 0 if justify == Justify.RIGHT: dx = box.width - sum(i.length for i in line) elif justify == Justify.CENTER: dx = (box.width - sum(i.length for i in line)) // 2 for segment in chain.from_iterable(g.segments for g in line): res.draw_string_line( frame.with_style(frame.default_style.apply_rule(segment.rule)), segment.text, box.position + Coordinate(dx, dy) ) dx += frame.measure_text(segment.text) return res # adaptive_text("hej", span("hej", fg=Color.RED), "hejsan guys\n") @cache def _split_by_spaces(s: str, rule: StyleRule, measure_text: MeasureTextFunc): r = filter(lambda x: x!='',re.split(r'(\s+)', s)) return [Segment(t, rule, measure_text(t)) for t in r] def _append_segment_to_line(line: list[Group], seg: Segment): if len(line) and (seg.text.isspace() == line[-1].is_space): line[-1] = line[-1].extend(seg) return line.append(Group((seg,), seg.text.isspace())) def _extend_line_with_segments(line: list[Group], segments: list[Segment]): for s in segments: _append_segment_to_line(line, s) @cache def _span_to_lines(span: Span, measure_text: MeasureTextFunc) -> list[list[Group]]: out_lines = [[]] for t in span.text: if isinstance(t, str): lines = t.splitlines() if len(lines) <= 1: _extend_line_with_segments( out_lines[-1], _split_by_spaces(t, span.rule, measure_text) ) continue # elif len(lines) == 0: # return [[]] lines_iter = iter(lines) last_elem = next(lines_iter) _extend_line_with_segments( out_lines[-1], _split_by_spaces(last_elem, span.rule, measure_text) ) for line in lines_iter: out_lines.append([]) _extend_line_with_segments( out_lines[-1], _split_by_spaces(line, span.rule, measure_text) ) continue child_res = _span_to_lines( Span( text=t.text, rule=span.rule | t.rule ), measure_text ) child_lines_iter = iter(child_res) first_child_line = next(child_lines_iter) first_child_group = first_child_line[0] _extend_line_with_segments( out_lines[-1], list(first_child_group.segments) ) out_lines[-1].extend(first_child_line[1:]) for line in child_lines_iter: out_lines.append(line) return out_lines
[docs] def span(*text: str | Span, rule: StyleRule): """Style a text segment in an :obj:`adaptive_text` node.""" return Span(text, rule)
def _wrap_word( group: Group, out: list[list[Group]], max_width: int, continuation_str_width: int, continuation_str: str, measure_text: MeasureTextFunc ): prefix, left_over = group.split(max_width - continuation_str_width, measure_text) # if nothing fits if len(prefix.segments) == 0: return # dont even try segments = (*prefix.segments, Segment( continuation_str, prefix.segments[-1].rule, continuation_str_width )) prefix = Group(segments, False) out[-1].append(prefix) if left_over.length > max_width: out.append([]) _wrap_word( left_over, out, max_width, continuation_str_width, continuation_str, measure_text ) return out.append([left_over]) def wrap_line_default(line: Iterable[Group], max_width: int, measure_text: MeasureTextFunc, continuation_str: str="-") -> list[list[Group]]: continuation_str_width = measure_text(continuation_str) out = [[]] curr_len = 0 for group in line: # ignore trailing space if group.is_space and curr_len == 0: continue if group.length + curr_len > max_width: if curr_len == 0 and not group.is_space: # if word is longer than line, then split it _wrap_word( group, out, max_width, continuation_str_width, continuation_str, measure_text ) curr_len = out[-1][-1].length if len(out[-1]) else 0 continue # start new line, if it does not start with space out.append([] if group.is_space else [group]) curr_len = 0 if group.is_space else group.length continue out[-1].append(group) curr_len += group.length return out if not out[-1] == [] else out[:-1]