From 6f82ad9c4a2e17812a68d3c76d7eae89aee3a515 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Dec 2022 18:06:35 +0000 Subject: [PATCH 01/40] adds Strip primitive --- src/textual/_compositor.py | 68 +- src/textual/_segment_tools.py | 10 +- src/textual/_styles_cache.py | 75 +- src/textual/_types.py | 6 +- src/textual/scroll_view.py | 14 +- src/textual/strip.py | 181 + src/textual/widget.py | 11 +- src/textual/widgets/_data_table.py | 10 +- src/textual/widgets/_text_log.py | 4 +- .../__snapshots__/test_snapshots.ambr | 4030 ++++++++--------- tests/test_styles_cache.py | 12 +- 11 files changed, 2294 insertions(+), 2127 deletions(-) create mode 100644 src/textual/strip.py diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 75c7b973f..7ca7c4251 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,10 +26,12 @@ from rich.style import Style from . import errors from ._cells import cell_len from ._loop import loop_last -from ._types import Lines +from .strip import Strip +from ._types import Strips from ._typing import TypeAlias from .geometry import NULL_OFFSET, Offset, Region, Size + if TYPE_CHECKING: from .widget import Widget @@ -66,8 +68,8 @@ CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" class LayoutUpdate: """A renderable containing the result of a render for a given region.""" - def __init__(self, lines: Lines, region: Region) -> None: - self.lines = lines + def __init__(self, strips: Strips, region: Region) -> None: + self.strips = strips self.region = region def __rich_console__( @@ -76,7 +78,7 @@ class LayoutUpdate: x = self.region.x new_line = Segment.line() move_to = Control.move_to - for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): + for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)): yield move_to(x, y) yield from line if not last: @@ -92,7 +94,7 @@ class ChopsUpdate: def __init__( self, - chops: list[dict[int, list[Segment] | None]], + chops: list[dict[int, Strip | None]], spans: list[tuple[int, int, int]], chop_ends: list[list[int]], ) -> None: @@ -121,9 +123,9 @@ class ChopsUpdate: for y, x1, x2 in self.spans: line = chops[y] ends = chop_ends[y] - for end, (x, segments) in zip(ends, line.items()): + for end, (x, strip) in zip(ends, line.items()): # TODO: crop to x extents - if segments is None: + if strip is None: continue if x > x2 or end <= x1: @@ -131,10 +133,10 @@ class ChopsUpdate: if x2 > x >= x1 and end <= x2: yield move_to(x, y) - yield from segments + yield from strip continue - iter_segments = iter(segments) + iter_segments = iter(strip) if x < x1: for segment in iter_segments: next_x = x + _cell_len(segment.text) @@ -635,7 +637,7 @@ class Compositor: def _get_renders( self, crop: Region | None = None - ) -> Iterable[tuple[Region, Region, Lines]]: + ) -> Iterable[tuple[Region, Region, Strips]]: """Get rendered widgets (lists of segments) in the composition. Returns: @@ -685,19 +687,21 @@ class Compositor: _Region(delta_x, delta_y, new_width, new_height) ) - @classmethod - def _assemble_chops( - cls, chops: list[dict[int, list[Segment] | None]] - ) -> list[list[Segment]]: - """Combine chops in to lines.""" - from_iterable = chain.from_iterable - segment_lines: list[list[Segment]] = [ - list(from_iterable(line for line in bucket.values() if line is not None)) - for bucket in chops - ] - return segment_lines + # @classmethod + # def _assemble_chops(cls, chops: list[dict[int, Strip | None]]) -> list[Strip]: + # """Combine chops in to lines.""" - def render(self, full: bool = False) -> RenderableType | None: + # [Strip.join(strips) for strips in chops] + + # from_iterable = chain.from_iterable + + # segment_lines: list[list[Segment]] = [ + # list(from_iterable(strip for strip in bucket.values() if strip is not None)) + # for bucket in chops + # ] + # return segment_lines + + def render(self, full: bool = False) -> RenderableType: """Render a layout. Returns: @@ -728,8 +732,6 @@ class Compositor: else: return None - divide = Segment.divide - # Maps each cut on to a list of segments cuts = self.cuts @@ -738,19 +740,19 @@ class Compositor: "Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys ) # A mapping of cut index to a list of segments for each line - chops: list[dict[int, list[Segment] | None]] + chops: list[dict[int, Strip | None]] chops = [fromkeys(cut_set[:-1]) for cut_set in cuts] - cut_segments: Iterable[list[Segment]] + cut_strips: Iterable[Strip] # Go through all the renders in reverse order and fill buckets with no render renders = self._get_renders(crop) intersection = Region.intersection - for region, clip, lines in renders: + for region, clip, strips in renders: render_region = intersection(region, clip) - for y, line in zip(render_region.line_range, lines): + for y, strip in zip(render_region.line_range, strips): if not is_rendered_line(y): continue @@ -763,20 +765,20 @@ class Compositor: ] if len(final_cuts) <= 2: # Two cuts, which means the entire line - cut_segments = [line] + cut_strips = [strip] else: render_x = render_region.x relative_cuts = [cut - render_x for cut in final_cuts[1:]] - cut_segments = divide(line, relative_cuts) + cut_strips = list(strip.divide(relative_cuts)) # Since we are painting front to back, the first segments for a cut "wins" - for cut, segments in zip(final_cuts, cut_segments): + for cut, segments in zip(final_cuts, cut_strips): if chops_line[cut] is None: chops_line[cut] = segments if full: - render_lines = self._assemble_chops(chops) - return LayoutUpdate(render_lines, screen_region) + render_strips = [Strip.join(chop.values()) for chop in chops] + return LayoutUpdate(render_strips, screen_region) else: chop_ends = [cut_set[1:] for cut_set in cuts] return ChopsUpdate(chops, spans, chop_ends) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index b2e4a13f7..abb783496 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -10,7 +10,7 @@ from rich.segment import Segment from rich.style import Style from ._cells import cell_len -from ._types import Lines +from ._types import Strips from .css.types import AlignHorizontal, AlignVertical from .geometry import Size @@ -22,8 +22,8 @@ def line_crop( Args: segments (list[Segment]): A list of Segments for a line. - start (int): Start offset - end (int): End offset (exclusive) + start (int): Start offset (cells) + end (int): End offset (cells, exclusive) total (int): Total cell length of segments. Returns: list[Segment]: A new shorter list of segments @@ -130,7 +130,7 @@ def line_pad( def align_lines( - lines: Lines, + lines: Strips, style: Style, size: Size, horizontal: AlignHorizontal, @@ -153,7 +153,7 @@ def align_lines( width, height = size shape_width, shape_height = Segment.get_shape(lines) - def blank_lines(count: int) -> Lines: + def blank_lines(count: int) -> Strips: return [[Segment(" " * width, style)]] * count top_blank_lines = bottom_blank_lines = 0 diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 8b4e62996..e99c289fd 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -11,12 +11,13 @@ from ._border import get_box, render_row from ._filter import LineFilter from ._opacity import _apply_opacity from ._segment_tools import line_crop, line_pad, line_trim -from ._types import Lines +from ._types import Strips from ._typing import TypeAlias from .color import Color from .geometry import Region, Size, Spacing from .renderables.text_opacity import TextOpacity from .renderables.tint import Tint +from .strip import Strip if TYPE_CHECKING: from .css.styles import StylesBase @@ -25,35 +26,6 @@ if TYPE_CHECKING: RenderLineCallback: TypeAlias = Callable[[int], List[Segment]] -def style_links( - segments: Iterable[Segment], link_id: str, link_style: Style -) -> list[Segment]: - """Apply a style to the given link id. - - Args: - segments (Iterable[Segment]): Segments. - link_id (str): A link id. - link_style (Style): Style to apply. - - Returns: - list[Segment]: A list of new segments. - """ - - _Segment = Segment - - segments = [ - _Segment( - text, - (style + link_style if style is not None else None) - if (style and not style._null and style._link_id == link_id) - else style, - control, - ) - for text, style, control in segments - ] - return segments - - @lru_cache(1024 * 8) def make_blank(width, style: Style) -> Segment: """Make a blank segment. @@ -95,7 +67,7 @@ class StylesCache: """ def __init__(self) -> None: - self._cache: dict[int, list[Segment]] = {} + self._cache: dict[int, Strip] = {} self._dirty_lines: set[int] = set() self._width = 1 @@ -123,7 +95,7 @@ class StylesCache: self._cache.clear() self._dirty_lines.clear() - def render_widget(self, widget: Widget, crop: Region) -> Lines: + def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: """Render the content for a widget. Args: @@ -135,7 +107,7 @@ class StylesCache: """ base_background, background = widget.background_colors styles = widget.styles - lines = self.render( + strips = self.render( styles, widget.region.size, base_background, @@ -147,7 +119,6 @@ class StylesCache: filter=widget.app._filter, ) if widget.auto_links: - _style_links = style_links hover_style = widget.hover_style link_hover_style = widget.link_hover_style if ( @@ -157,12 +128,12 @@ class StylesCache: and "@click" in hover_style.meta ): if link_hover_style: - lines = [ - _style_links(line, hover_style.link_id, link_hover_style) - for line in lines + strips = [ + strip.style_links(hover_style.link_id, link_hover_style) + for strip in strips ] - return lines + return strips def render( self, @@ -175,7 +146,7 @@ class StylesCache: padding: Spacing | None = None, crop: Region | None = None, filter: LineFilter | None = None, - ) -> Lines: + ) -> list[Strip]: """Render a widget content plus CSS styles. Args: @@ -202,15 +173,14 @@ class StylesCache: if width != self._width: self.clear() self._width = width - lines: Lines = [] - add_line = lines.append - simplify = Segment.simplify + strips: list[Strip] = [] + add_strip = strips.append is_dirty = self._dirty_lines.__contains__ render_line = self.render_line for y in crop.line_range: if is_dirty(y) or y not in self._cache: - line = render_line( + strip = render_line( styles, y, size, @@ -220,21 +190,19 @@ class StylesCache: background, render_content_line, ) - line = list(simplify(line)) - self._cache[y] = line + self._cache[y] = strip else: - line = self._cache[y] + strip = self._cache[y] if filter: - line = filter.filter(line) - add_line(line) + strip = strip.apply_filter(filter) + add_strip(strip) self._dirty_lines.difference_update(crop.line_range) if crop.column_span != (0, width): - _line_crop = line_crop x1, x2 = crop.column_span - lines = [_line_crop(line, x1, x2, width) for line in lines] + strips = [strip.crop(x1, x2) for strip in strips] - return lines + return strips def render_line( self, @@ -246,7 +214,7 @@ class StylesCache: base_background: Color, background: Color, render_content_line: RenderLineCallback, - ) -> list[Segment]: + ) -> Strip: """Render a styled line. Args: @@ -402,4 +370,5 @@ class StylesCache: else: line = [*line, right] - return post(line) + strip = Strip(post(line), width) + return strip diff --git a/src/textual/_types.py b/src/textual/_types.py index cfb4af035..4df812b89 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -2,10 +2,12 @@ from typing import Awaitable, Callable, List, TYPE_CHECKING, Union from rich.segment import Segment -from textual._typing import Protocol +from ._typing import Protocol + if TYPE_CHECKING: from .message import Message + from .strip import Strip class MessageTarget(Protocol): @@ -27,5 +29,5 @@ class EventTarget(Protocol): ... -Lines = List[List[Segment]] +Strips = List["Strip"] CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index e6e692480..0c8abd0bf 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -30,6 +30,16 @@ class ScrollView(Widget): """Not transparent, i.e. renders something.""" return False + def watch_scroll_x(self, new_value: float) -> None: + if self.show_horizontal_scrollbar: + self.horizontal_scrollbar.position = int(new_value) + self.refresh() + + def watch_scroll_y(self, new_value: float) -> None: + if self.show_vertical_scrollbar: + self.vertical_scrollbar.position = int(new_value) + self.refresh() + def on_mount(self): self._refresh_scrollbars() @@ -68,6 +78,8 @@ class ScrollView(Widget): virtual_size (Size): New virtual size. container_size (Size): New container size. """ + if self._size != size or container_size != container_size: + self.refresh() if ( self._size != size or virtual_size != self.virtual_size @@ -77,9 +89,7 @@ class ScrollView(Widget): virtual_size = self.virtual_size self._container_size = size - self.styles.gutter.totals self._scroll_update(virtual_size) - self.scroll_to(self.scroll_x, self.scroll_y, animate=False) - self.refresh() def render(self) -> RenderableType: """Render the scrollable region (if `render_lines` is not implemented). diff --git a/src/textual/strip.py b/src/textual/strip.py new file mode 100644 index 000000000..77078ebcc --- /dev/null +++ b/src/textual/strip.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from itertools import chain +from typing import Iterator, Iterable, Sequence + +import rich.repr +from rich.cells import cell_len, set_cell_size +from rich.segment import Segment +from rich.style import Style + +from ._filter import LineFilter +from ._segment_tools import line_crop + +from ._profile import timer + + +@rich.repr.auto +class Strip: + __slots__ = ["_segments", "_cell_length", "_divide_cache"] + + def __init__( + self, segments: Iterable[Segment], cell_length: int | None = None + ) -> None: + self._segments = list(segments) + self._cell_length = cell_length + self._divide_cache: dict[tuple[int], list[Strip]] = {} + + def __rich_repr__(self) -> rich.repr.Result: + yield self._segments + yield self.cell_length + + @property + def cell_length(self) -> int: + """Get the number of cells required to render this object.""" + # Done on demand and cached, as this is an O(n) operation + if self._cell_length is None: + self._cell_length = Segment.get_line_length(self._segments) + return self._cell_length + + @classmethod + def join(cls, strips: Iterable[Strip | None]) -> Strip: + + segments: list[list[Segment]] = [] + add_segments = segments.append + total_cell_length = 0 + for strip in strips: + if strip is None: + continue + total_cell_length += strip.cell_length + add_segments(strip._segments) + strip = cls(chain.from_iterable(segments), total_cell_length) + return strip + + def __bool__(self) -> bool: + return bool(self._segments) + + def __iter__(self) -> Iterator[Segment]: + return iter(self._segments) + + def __len__(self) -> int: + return len(self._segments) + + def __eq__(self, strip: Strip) -> bool: + return ( + self._segments == strip._segments and self.cell_length == strip.cell_length + ) + + def adjust_line_length(self, cell_length: int, style: Style | None) -> Strip: + + new_line: list[Segment] + line = self._segments + current_cell_length = self.cell_length + + _Segment = Segment + + if current_cell_length < cell_length: + new_line = line + [ + _Segment(" " * (cell_length - current_cell_length), style) + ] + + elif current_cell_length > cell_length: + new_line = [] + append = new_line.append + line_length = 0 + for segment in line: + segment_length = segment.cell_length + if line_length + segment_length < cell_length: + append(segment) + line_length += segment_length + else: + text, segment_style, _ = segment + text = set_cell_size(text, cell_length - line_length) + append(_Segment(text, segment_style)) + break + else: + return self + + return Strip(new_line, cell_length) + + def simplify(self) -> Strip: + line = Strip( + Segment.simplify(self._segments), + self._cell_length, + ) + return line + + def apply_filter(self, filter: LineFilter) -> Strip: + return Strip(filter.filter(self._segments), self._cell_length) + + def style_links(self, link_id: str, link_style: Style) -> Strip: + _Segment = Segment + if not any( + segment.style._link_id == link_id + for segment in self._segments + if segment.style + ): + return self + segments = [ + _Segment( + text, + (style + link_style if style is not None else None) + if (style and not style._null and style._link_id == link_id) + else style, + control, + ) + for text, style, control in self._segments + ] + return Strip(segments, self._cell_length) + + def crop(self, start: int, end: int) -> Strip: + _cell_len = cell_len + pos = 0 + output_segments: list[Segment] = [] + add_segment = output_segments.append + iter_segments = iter(self._segments) + segment: Segment | None = None + for segment in iter_segments: + end_pos = pos + _cell_len(segment.text) + if end_pos > start: + segment = segment.split_cells(start - pos)[1] + break + pos = end_pos + else: + return Strip([], 0) + + if end >= self.cell_length: + # The end crop is the end of the segments, so we can collect all remaining segments + if segment: + add_segment(segment) + output_segments.extend(iter_segments) + return Strip(output_segments, self.cell_length - start) + + pos = start + while segment is not None: + end_pos = pos + _cell_len(segment.text) + if end_pos < end: + add_segment(segment) + else: + add_segment(segment.split_cells(end - pos)[0]) + break + pos = end_pos + segment = next(iter_segments, None) + return Strip(output_segments, end - start) + + def divide(self, cuts: Iterable[int]) -> list[Strip]: + + pos = 0 + cache_key = tuple(cuts) + cached = self._divide_cache.get(cache_key) + if cached is not None: + return cached + + strips: list[Strip] = [] + add_strip = strips.append + for segments, cut in zip(Segment.divide(self._segments, cuts), cuts): + add_strip(Strip(segments, cut - pos)) + pos += cut + + self._divide_cache[cache_key] = strips + + return strips diff --git a/src/textual/widget.py b/src/textual/widget.py index a74449272..69b1b2afa 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -43,7 +43,7 @@ from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache -from ._types import Lines +from ._types import Strips from .actions import SkipAction from .await_remove import AwaitRemove from .binding import Binding @@ -57,6 +57,7 @@ from .message import Message from .messages import CallbackType from .reactive import Reactive from .render import measure +from .strip import Strip from .walk import walk_depth_first if TYPE_CHECKING: @@ -156,7 +157,7 @@ class RenderCache(NamedTuple): """Stores results of a previous render.""" size: Size - lines: Lines + lines: Strips class WidgetError(Exception): @@ -2118,7 +2119,7 @@ class Widget(DOMNode): line = [Segment(" " * self.size.width, self.rich_style)] return line - def render_lines(self, crop: Region) -> Lines: + def render_lines(self, crop: Region) -> list[Strip]: """Render the widget in to lines. Args: @@ -2127,8 +2128,8 @@ class Widget(DOMNode): Returns: Lines: A list of list of segments. """ - lines = self._styles_cache.render_widget(self, crop) - return lines + strips = self._styles_cache.render_widget(self, crop) + return strips def get_style_at(self, x: int, y: int) -> Style: """Get the Rich style in a widget at a given relative offset. diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 5d6d0a0e0..6b8eb5f38 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -14,7 +14,7 @@ from rich.text import Text, TextType from .. import events, messages from .._cache import LRUCache from .._segment_tools import line_crop -from .._types import Lines +from .._types import Strips from ..geometry import Region, Size, Spacing, clamp from ..reactive import Reactive from ..render import measure @@ -207,10 +207,10 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.row_count = 0 self._y_offsets: list[tuple[int, int]] = [] self._row_render_cache: LRUCache[ - tuple[int, int, Style, int, int], tuple[Lines, Lines] + tuple[int, int, Style, int, int], tuple[Strips, Strips] ] self._row_render_cache = LRUCache(1000) - self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Lines] + self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Strips] self._cell_render_cache = LRUCache(10000) self._line_cache: LRUCache[ tuple[int, int, int, int, int, int, Style], list[Segment] @@ -450,7 +450,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): width: int, cursor: bool = False, hover: bool = False, - ) -> Lines: + ) -> Strips: """Render the given cell. Args: @@ -488,7 +488,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): base_style: Style, cursor_column: int = -1, hover_column: int = -1, - ) -> tuple[Lines, Lines]: + ) -> tuple[Strips, Strips]: """Render a row in to lines for each cell. Args: diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index 980df5f4c..1b420f95b 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -15,7 +15,7 @@ from ..geometry import Size, Region from ..scroll_view import ScrollView from .._cache import LRUCache from .._segment_tools import line_crop -from .._types import Lines +from .._types import Strips class TextLog(ScrollView, can_focus=True): @@ -143,7 +143,7 @@ class TextLog(ScrollView, can_focus=True): line = list(Segment.apply_style(line, self.rich_style)) return line - def render_lines(self, crop: Region) -> Lines: + def render_lines(self, crop: Region) -> Strips: """Render the widget in to lines. Args: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 49f103500..a67872a63 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21,162 +21,162 @@ font-weight: 700; } - .terminal-481343241-matrix { + .terminal-3615181303-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-481343241-title { + .terminal-3615181303-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-481343241-r1 { fill: #e1e1e1 } - .terminal-481343241-r2 { fill: #c5c8c6 } - .terminal-481343241-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-481343241-r4 { fill: #454a50 } - .terminal-481343241-r5 { fill: #292b2e } - .terminal-481343241-r6 { fill: #24292f;font-weight: bold } - .terminal-481343241-r7 { fill: #555657;font-weight: bold } - .terminal-481343241-r8 { fill: #000000 } - .terminal-481343241-r9 { fill: #161617 } - .terminal-481343241-r10 { fill: #507bb3 } - .terminal-481343241-r11 { fill: #283c52 } - .terminal-481343241-r12 { fill: #dde6ed;font-weight: bold } - .terminal-481343241-r13 { fill: #4f5a62;font-weight: bold } - .terminal-481343241-r14 { fill: #001541 } - .terminal-481343241-r15 { fill: #122032 } - .terminal-481343241-r16 { fill: #7ae998 } - .terminal-481343241-r17 { fill: #3d6a4a } - .terminal-481343241-r18 { fill: #0a180e;font-weight: bold } - .terminal-481343241-r19 { fill: #1e2f23;font-weight: bold } - .terminal-481343241-r20 { fill: #008139 } - .terminal-481343241-r21 { fill: #1b4c2f } - .terminal-481343241-r22 { fill: #ffcf56 } - .terminal-481343241-r23 { fill: #775f2f } - .terminal-481343241-r24 { fill: #211505;font-weight: bold } - .terminal-481343241-r25 { fill: #392b18;font-weight: bold } - .terminal-481343241-r26 { fill: #b86b00 } - .terminal-481343241-r27 { fill: #644316 } - .terminal-481343241-r28 { fill: #e76580 } - .terminal-481343241-r29 { fill: #683540 } - .terminal-481343241-r30 { fill: #f5e5e9;font-weight: bold } - .terminal-481343241-r31 { fill: #6c595e;font-weight: bold } - .terminal-481343241-r32 { fill: #780028 } - .terminal-481343241-r33 { fill: #491928 } + .terminal-3615181303-r1 { fill: #e1e1e1 } + .terminal-3615181303-r2 { fill: #c5c8c6 } + .terminal-3615181303-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-3615181303-r4 { fill: #454a50 } + .terminal-3615181303-r5 { fill: #292b2e } + .terminal-3615181303-r6 { fill: #24292f;font-weight: bold } + .terminal-3615181303-r7 { fill: #555657;font-weight: bold } + .terminal-3615181303-r8 { fill: #000000 } + .terminal-3615181303-r9 { fill: #161617 } + .terminal-3615181303-r10 { fill: #507bb3 } + .terminal-3615181303-r11 { fill: #283c52 } + .terminal-3615181303-r12 { fill: #dde6ed;font-weight: bold } + .terminal-3615181303-r13 { fill: #4f5a62;font-weight: bold } + .terminal-3615181303-r14 { fill: #001541 } + .terminal-3615181303-r15 { fill: #122032 } + .terminal-3615181303-r16 { fill: #7ae998 } + .terminal-3615181303-r17 { fill: #3d6a4a } + .terminal-3615181303-r18 { fill: #0a180e;font-weight: bold } + .terminal-3615181303-r19 { fill: #1e2f23;font-weight: bold } + .terminal-3615181303-r20 { fill: #008139 } + .terminal-3615181303-r21 { fill: #1b4c2f } + .terminal-3615181303-r22 { fill: #ffcf56 } + .terminal-3615181303-r23 { fill: #775f2f } + .terminal-3615181303-r24 { fill: #211505;font-weight: bold } + .terminal-3615181303-r25 { fill: #392b18;font-weight: bold } + .terminal-3615181303-r26 { fill: #b86b00 } + .terminal-3615181303-r27 { fill: #644316 } + .terminal-3615181303-r28 { fill: #e76580 } + .terminal-3615181303-r29 { fill: #683540 } + .terminal-3615181303-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-3615181303-r31 { fill: #6c595e;font-weight: bold } + .terminal-3615181303-r32 { fill: #780028 } + .terminal-3615181303-r33 { fill: #491928 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Default  Default  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Primary!  Primary!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Success!  Success!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Warning!  Warning!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Error!  Error!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DefaultDefault + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Primary!Primary! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Success!Success! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Warning!Warning! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Error!Error! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -207,136 +207,136 @@ font-weight: 700; } - .terminal-1548740802-matrix { + .terminal-1461952564-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1548740802-title { + .terminal-1461952564-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1548740802-r1 { fill: #e1e1e1 } - .terminal-1548740802-r2 { fill: #c5c8c6 } - .terminal-1548740802-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-1548740802-r4 { fill: #1e1e1e } - .terminal-1548740802-r5 { fill: #0178d4 } - .terminal-1548740802-r6 { fill: #e2e3e3 } - .terminal-1548740802-r7 { fill: #e3e8e8 } + .terminal-1461952564-r1 { fill: #e1e1e1 } + .terminal-1461952564-r2 { fill: #c5c8c6 } + .terminal-1461952564-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-1461952564-r4 { fill: #1e1e1e } + .terminal-1461952564-r5 { fill: #0178d4 } + .terminal-1461952564-r6 { fill: #e2e3e3 } + .terminal-1461952564-r7 { fill: #e3e8e8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CheckboxApp + CheckboxApp - - - - - - - - Example checkboxes - - - ▔▔▔▔▔▔▔▔ - off:      - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - on:       - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - focused:  - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - custom:   - ▁▁▁▁▁▁▁▁ - - - - + + + + + + + + Example checkboxes + + + ▔▔▔▔▔▔▔▔ + off:      + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + on:       + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + focused:  + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + custom:   + ▁▁▁▁▁▁▁▁ + + + + @@ -367,133 +367,133 @@ font-weight: 700; } - .terminal-2010068486-matrix { + .terminal-363813734-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2010068486-title { + .terminal-363813734-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2010068486-r1 { fill: #ff0000 } - .terminal-2010068486-r2 { fill: #c5c8c6 } - .terminal-2010068486-r3 { fill: #008000 } - .terminal-2010068486-r4 { fill: #e1e1e1 } + .terminal-363813734-r1 { fill: #ff0000 } + .terminal-363813734-r2 { fill: #c5c8c6 } + .terminal-363813734-r3 { fill: #008000 } + .terminal-363813734-r4 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeightApp + HeightApp - - - - ┌──────────────────────────────────────────────────────────────────────────────┐ - ┌────────────────────┐┌────────────────┐┌──────────────────────┐ - As tall as containerThis has defaultI have a static height - height - but a - few lines - └────────────────┘ - - - - - - - - - - └────────────────────┘└──────────────────────┘ - └──────────────────────────────────────────────────────────────────────────────┘ - - - - - + + + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────── + As tall as containerThis has defaultI have a static height + height + but a + few lines + ──────────────── + + + + + + + + + + ────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + + @@ -524,134 +524,134 @@ font-weight: 700; } - .terminal-4107518032-matrix { + .terminal-1567237307-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4107518032-title { + .terminal-1567237307-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4107518032-r1 { fill: #e1e1e1 } - .terminal-4107518032-r2 { fill: #c5c8c6 } - .terminal-4107518032-r3 { fill: #ffffff } - .terminal-4107518032-r4 { fill: #e5f2e5 } - .terminal-4107518032-r5 { fill: #e5f2e5;font-weight: bold } + .terminal-1567237307-r1 { fill: #e1e1e1 } + .terminal-1567237307-r2 { fill: #c5c8c6 } + .terminal-1567237307-r3 { fill: #ffffff } + .terminal-1567237307-r4 { fill: #e5f2e5 } + .terminal-1567237307-r5 { fill: #e5f2e5;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignApp + AlignApp - - - - - - - - - - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - - Vertical alignment with Textual - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - - Take note, browsers. - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - - - - - + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Vertical alignment with Textual + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + Take note, browsers. + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + @@ -837,134 +837,134 @@ font-weight: 700; } - .terminal-3021441172-matrix { + .terminal-1839441138-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3021441172-title { + .terminal-1839441138-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3021441172-r1 { fill: #ffffff } - .terminal-3021441172-r2 { fill: #c5c8c6 } - .terminal-3021441172-r3 { fill: #ff0000 } - .terminal-3021441172-r4 { fill: #008000 } - .terminal-3021441172-r5 { fill: #0000ff } + .terminal-1839441138-r1 { fill: #ffffff } + .terminal-1839441138-r2 { fill: #c5c8c6 } + .terminal-1839441138-r3 { fill: #ff0000 } + .terminal-1839441138-r4 { fill: #008000 } + .terminal-1839441138-r5 { fill: #0000ff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BorderApp + BorderApp - - - - - ┌────────────────────────────────────────────────────────────────────────────┐ - - My border is solid red - - └────────────────────────────────────────────────────────────────────────────┘ - - ┏╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┓ - - My border is dashed green - - ┗╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┛ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - My border is tall blue - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - + + + + + ──────────────────────────────────────────────────────────────────────────── + + My border is solid red + + ──────────────────────────────────────────────────────────────────────────── + + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + My border is dashed green + + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + My border is tall blue + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + @@ -995,132 +995,132 @@ font-weight: 700; } - .terminal-3248705240-matrix { + .terminal-1232593861-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3248705240-title { + .terminal-1232593861-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3248705240-r1 { fill: #000000 } - .terminal-3248705240-r2 { fill: #c5c8c6 } - .terminal-3248705240-r3 { fill: #ccccff } + .terminal-1232593861-r1 { fill: #000000 } + .terminal-1232593861-r2 { fill: #c5c8c6 } + .terminal-1232593861-r3 { fill: #ccccff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BoxSizingApp + BoxSizingApp - - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - I'm using border-box! - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - I'm using content-box! - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + I'm using border-box! + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + I'm using content-box! + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + @@ -1308,133 +1308,133 @@ font-weight: 700; } - .terminal-2684005981-matrix { + .terminal-1585086532-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2684005981-title { + .terminal-1585086532-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2684005981-r1 { fill: #c5c8c6 } - .terminal-2684005981-r2 { fill: #ffffff } - .terminal-2684005981-r3 { fill: #ffffff;font-style: italic; } - .terminal-2684005981-r4 { fill: #ffffff;font-weight: bold } + .terminal-1585086532-r1 { fill: #c5c8c6 } + .terminal-1585086532-r2 { fill: #ffffff } + .terminal-1585086532-r3 { fill: #ffffff;font-style: italic; } + .terminal-1585086532-r4 { fill: #ffffff;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ContentAlignApp + ContentAlignApp - - - - - With content-align you can... - - - - - - - - - - ...Easily align content... - - - - - - - - - - - ...Horizontally and vertically! + + + + + With content-align you can... + + + + + + + + + + ...Easily align content... + + + + + + + + + + + ...Horizontally and vertically! @@ -1465,132 +1465,132 @@ font-weight: 700; } - .terminal-2110623858-matrix { + .terminal-3544266701-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2110623858-title { + .terminal-3544266701-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2110623858-r1 { fill: #0000ff } - .terminal-2110623858-r2 { fill: #c5c8c6 } - .terminal-2110623858-r3 { fill: #ddeedd } + .terminal-3544266701-r1 { fill: #0000ff } + .terminal-3544266701-r2 { fill: #c5c8c6 } + .terminal-3544266701-r3 { fill: #ddeedd } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DisplayApp + DisplayApp - - - - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃Widget 1 - - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃Widget 3 - - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - - - - - - - - - - - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Widget 1 + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Widget 3 + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + + + + + + + + @@ -1621,133 +1621,133 @@ font-weight: 700; } - .terminal-3791676016-matrix { + .terminal-2927206876-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3791676016-title { + .terminal-2927206876-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3791676016-r1 { fill: #c5c8c6 } - .terminal-3791676016-r2 { fill: #e1e1e1 } - .terminal-3791676016-r3 { fill: #731077 } - .terminal-3791676016-r4 { fill: #161c1d } + .terminal-2927206876-r1 { fill: #c5c8c6 } + .terminal-2927206876-r2 { fill: #e1e1e1 } + .terminal-2927206876-r3 { fill: #731077 } + .terminal-2927206876-r4 { fill: #161c1d } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - GridApp + GridApp - - - - - Grid cell 1Grid cell 2 - - row-span: 3; - column-span: 2; - - - Grid cell 3 - - - - - - Grid cell 4 - - - - - - Grid cell 5Grid cell 6Grid cell 7 - - - + + + + + Grid cell 1Grid cell 2 + + row-span: 3; + column-span: 2; + + + Grid cell 3 + + + + + + Grid cell 4 + + + + + + Grid cell 5Grid cell 6Grid cell 7 + + + @@ -2249,133 +2249,133 @@ font-weight: 700; } - .terminal-2799621938-matrix { + .terminal-211150573-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2799621938-title { + .terminal-211150573-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2799621938-r1 { fill: #000000 } - .terminal-2799621938-r2 { fill: #c5c8c6 } - .terminal-2799621938-r3 { fill: #0000ff } - .terminal-2799621938-r4 { fill: #ccccff } + .terminal-211150573-r1 { fill: #000000 } + .terminal-211150573-r2 { fill: #c5c8c6 } + .terminal-211150573-r3 { fill: #0000ff } + .terminal-211150573-r4 { fill: #ccccff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarginApp + MarginApp - - - - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - I must not fear. - Fear is the mind-killer. - Fear is the little-death that brings total obliteration. - I will face my fear. - I will permit it to pass over me and through me. - And when it has gone past, I will turn the inner eye to see  - its path. - Where the fear has gone there will be nothing. Only I will  - remain. - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total obliteration. + I will face my fear. + I will permit it to pass over me and through me. + And when it has gone past, I will turn the inner eye to see  + its path. + Where the fear has gone there will be nothing. Only I will  + remain. + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + @@ -2406,134 +2406,134 @@ font-weight: 700; } - .terminal-2080475776-matrix { + .terminal-4002837244-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2080475776-title { + .terminal-4002837244-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2080475776-r1 { fill: #000000 } - .terminal-2080475776-r2 { fill: #c5c8c6 } - .terminal-2080475776-r3 { fill: #ff0000 } - .terminal-2080475776-r4 { fill: #0000ff } - .terminal-2080475776-r5 { fill: #008000 } + .terminal-4002837244-r1 { fill: #000000 } + .terminal-4002837244-r2 { fill: #c5c8c6 } + .terminal-4002837244-r3 { fill: #ff0000 } + .terminal-4002837244-r4 { fill: #0000ff } + .terminal-4002837244-r5 { fill: #008000 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OffsetApp + OffsetApp - - - - - - ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ - - - ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ - Paul (offset 8 2) - - - Chani (offset 0 5)▐ - ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - ▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - Duncan (offset ▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟ - 10) - - - - ▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟ - - - + + + + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + Paul (offset 8 2) + + + Chani (offset 0 5) + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + Duncan (offset ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + 10) + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + @@ -2564,140 +2564,140 @@ font-weight: 700; } - .terminal-245581281-matrix { + .terminal-2191428201-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-245581281-title { + .terminal-2191428201-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-245581281-r1 { fill: #fefcf9 } - .terminal-245581281-r2 { fill: #c5c8c6 } - .terminal-245581281-r3 { fill: #c3d4e1 } - .terminal-245581281-r4 { fill: #f4edde;font-weight: bold } - .terminal-245581281-r5 { fill: #8cbdeb } - .terminal-245581281-r6 { fill: #eeefe5;font-weight: bold } - .terminal-245581281-r7 { fill: #55a6f5 } - .terminal-245581281-r8 { fill: #e8f1ec;font-weight: bold } - .terminal-245581281-r9 { fill: #1e90ff } - .terminal-245581281-r10 { fill: #e2f4f3;font-weight: bold } + .terminal-2191428201-r1 { fill: #fefcf9 } + .terminal-2191428201-r2 { fill: #c5c8c6 } + .terminal-2191428201-r3 { fill: #c3d4e1 } + .terminal-2191428201-r4 { fill: #f4edde;font-weight: bold } + .terminal-2191428201-r5 { fill: #8cbdeb } + .terminal-2191428201-r6 { fill: #eeefe5;font-weight: bold } + .terminal-2191428201-r7 { fill: #55a6f5 } + .terminal-2191428201-r8 { fill: #e8f1ec;font-weight: bold } + .terminal-2191428201-r9 { fill: #1e90ff } + .terminal-2191428201-r10 { fill: #e2f4f3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OpacityApp + OpacityApp - - - - - - - - ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ - - opacity: 25% - - ▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟ - ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ - - opacity: 50% - - ▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟ - ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ - - opacity: 75% - - ▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟ - ▛▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▜ - - opacity: 100% - - ▙▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▟ + + + + + + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + opacity: 25% + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + opacity: 50% + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + opacity: 75% + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + opacity: 100% + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ @@ -2727,133 +2727,133 @@ font-weight: 700; } - .terminal-2107256950-matrix { + .terminal-3260169885-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2107256950-title { + .terminal-3260169885-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2107256950-r1 { fill: #000000 } - .terminal-2107256950-r2 { fill: #c5c8c6 } - .terminal-2107256950-r3 { fill: #008000 } - .terminal-2107256950-r4 { fill: #cce5cc } + .terminal-3260169885-r1 { fill: #000000 } + .terminal-3260169885-r2 { fill: #c5c8c6 } + .terminal-3260169885-r3 { fill: #008000 } + .terminal-3260169885-r4 { fill: #cce5cc } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OutlineApp + OutlineApp - - - - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ear is the mind-killer. - ear is the little-death that brings total obliteration. -  will face my fear. -  will permit it to pass over me and through me. - nd when it has gone past, I will turn the inner eye to see its - ath. - here the fear has gone there will be nothing. Only I will  - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - - - + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ear is the mind-killer. + ear is the little-death that brings total obliteration. +  will face my fear. +  will permit it to pass over me and through me. + nd when it has gone past, I will turn the inner eye to see its + ath. + here the fear has gone there will be nothing. Only I will  + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + + + @@ -2884,136 +2884,136 @@ font-weight: 700; } - .terminal-1725099926-matrix { + .terminal-3616080938-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1725099926-title { + .terminal-3616080938-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1725099926-r1 { fill: #c5c8c6 } - .terminal-1725099926-r2 { fill: #000000 } - .terminal-1725099926-r3 { fill: #008000 } - .terminal-1725099926-r4 { fill: #e5f0e5 } - .terminal-1725099926-r5 { fill: #036a03 } - .terminal-1725099926-r6 { fill: #14191f } + .terminal-3616080938-r1 { fill: #c5c8c6 } + .terminal-3616080938-r2 { fill: #000000 } + .terminal-3616080938-r3 { fill: #008000 } + .terminal-3616080938-r4 { fill: #e5f0e5 } + .terminal-3616080938-r5 { fill: #036a03 } + .terminal-3616080938-r6 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OverflowApp + OverflowApp - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over meI will permit it to pass over me  - and through me.and through me. - And when it has gone past, I And when it has gone past, I will  - will turn the inner eye to see turn the inner eye to see its  - its path.▁▁path. - Where the fear has gone there Where the fear has gone there will - will be nothing. Only I will be nothing. Only I will remain. - remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. - I must not fear.Fear is the mind-killer. - Fear is the mind-killer.Fear is the little-death that  - Fear is the little-death that brings total obliteration. - brings total obliteration.I will face my fear. - I will face my fear.I will permit it to pass over me  - I will permit it to pass over meand through me. + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over meI will permit it to pass over me  + and through me.and through me. + And when it has gone past, I And when it has gone past, I will  + will turn the inner eye to see turn the inner eye to see its  + its path.▁▁path. + Where the fear has gone there Where the fear has gone there will + will be nothing. Only I will be nothing. Only I will remain. + remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. + I must not fear.Fear is the mind-killer. + Fear is the mind-killer.Fear is the little-death that  + Fear is the little-death that brings total obliteration. + brings total obliteration.I will face my fear. + I will face my fear.I will permit it to pass over me  + I will permit it to pass over meand through me. @@ -3043,131 +3043,131 @@ font-weight: 700; } - .terminal-1142797465-matrix { + .terminal-3291669704-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1142797465-title { + .terminal-3291669704-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1142797465-r1 { fill: #c5c8c6 } - .terminal-1142797465-r2 { fill: #0000ff } + .terminal-3291669704-r1 { fill: #c5c8c6 } + .terminal-3291669704-r2 { fill: #0000ff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - PaddingApp + PaddingApp - - - - - - - - I must not fear. - Fear is the mind-killer. - Fear is the little-death that brings total obliteration. - I will face my fear. - I will permit it to pass over me and through me. - And when it has gone past, I will turn the inner eye to see its  - path. - Where the fear has gone there will be nothing. Only I will  - remain. - - - - - - - - - - + + + + + + + + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total obliteration. + I will face my fear. + I will permit it to pass over me and through me. + And when it has gone past, I will turn the inner eye to see its  + path. + Where the fear has gone there will be nothing. Only I will  + remain. + + + + + + + + + + @@ -3510,133 +3510,133 @@ font-weight: 700; } - .terminal-2331479198-matrix { + .terminal-3032075324-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2331479198-title { + .terminal-3032075324-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2331479198-r1 { fill: #c5c8c6 } - .terminal-2331479198-r2 { fill: #d2d2d2 } - .terminal-2331479198-r3 { fill: #bbbbbb } - .terminal-2331479198-r4 { fill: #800080 } + .terminal-3032075324-r1 { fill: #c5c8c6 } + .terminal-3032075324-r2 { fill: #d2d2d2 } + .terminal-3032075324-r3 { fill: #bbbbbb } + .terminal-3032075324-r4 { fill: #800080 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollbarApp + ScrollbarApp - - - - - - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over I will permit it to pass over  - me and through me.▇▇me and through me.▇▇ - And when it has gone past, I And when it has gone past, I  - will turn the inner eye to seewill turn the inner eye to see - its path.its path. - Where the fear has gone there Where the fear has gone there  - will be nothing. Only I will will be nothing. Only I will  - remain.remain. - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over I will permit it to pass over  - me and through me.me and through me. - And when it has gone past, I And when it has gone past, I  + + + + + + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over I will permit it to pass over  + me and through me.▇▇me and through me.▇▇ + And when it has gone past, I And when it has gone past, I  + will turn the inner eye to seewill turn the inner eye to see + its path.its path. + Where the fear has gone there Where the fear has gone there  + will be nothing. Only I will will be nothing. Only I will  + remain.remain. + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over I will permit it to pass over  + me and through me.me and through me. + And when it has gone past, I And when it has gone past, I  @@ -3667,138 +3667,138 @@ font-weight: 700; } - .terminal-496646788-matrix { + .terminal-1285129494-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-496646788-title { + .terminal-1285129494-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-496646788-r1 { fill: #c5c8c6 } - .terminal-496646788-r2 { fill: #f4f9fb;font-weight: bold } - .terminal-496646788-r3 { fill: #f4f9fb } - .terminal-496646788-r4 { fill: #f8e9e9 } - .terminal-496646788-r5 { fill: #f8e9e9;font-weight: bold } - .terminal-496646788-r6 { fill: #f1fef1 } - .terminal-496646788-r7 { fill: #f1fef1;font-weight: bold } - .terminal-496646788-r8 { fill: #faecf0;font-weight: bold } - .terminal-496646788-r9 { fill: #faecf0 } + .terminal-1285129494-r1 { fill: #c5c8c6 } + .terminal-1285129494-r2 { fill: #f4f9fb;font-weight: bold } + .terminal-1285129494-r3 { fill: #f4f9fb } + .terminal-1285129494-r4 { fill: #f8e9e9 } + .terminal-1285129494-r5 { fill: #f8e9e9;font-weight: bold } + .terminal-1285129494-r6 { fill: #f1fef1 } + .terminal-1285129494-r7 { fill: #f1fef1;font-weight: bold } + .terminal-1285129494-r8 { fill: #faecf0;font-weight: bold } + .terminal-1285129494-r9 { fill: #faecf0 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TextAlign + TextAlign - - - - - Left aligned - I must not fear. Fear is the mind-killer. Fear is the little-death that brings - total obliteration. I will face my fear. I will permit it to pass over me and  - through me.                                                                    - - - Center aligned - I must not fear. Fear is the mind-killer. Fear is the little-death that brings - total obliteration. I will face my fear. I will permit it to pass over me and  -                                  through me.                                   - - - Right aligned - I must not fear. Fear is the mind-killer. Fear is the little-death that brings -  total obliteration. I will face my fear. I will permit it to pass over me and -                                                                    through me. - - - Justified - I must not fear. Fear is the mind-killer. Fear is the little-death that brings - total obliteration. I will face my fear. I will permit it to pass over me  and - through me. + + + + + Left aligned + I must not fear. Fear is the mind-killer. Fear is the little-death that brings + total obliteration. I will face my fear. I will permit it to pass over me and  + through me.                                                                    + + + Center aligned + I must not fear. Fear is the mind-killer. Fear is the little-death that brings + total obliteration. I will face my fear. I will permit it to pass over me and  +                                  through me.                                   + + + Right aligned + I must not fear. Fear is the mind-killer. Fear is the little-death that brings +  total obliteration. I will face my fear. I will permit it to pass over me and +                                                                    through me. + + + Justified + I must not fear. Fear is the mind-killer. Fear is the little-death that brings + total obliteration. I will face my fear. I will permit it to pass over me  and + through me. @@ -4309,132 +4309,132 @@ font-weight: 700; } - .terminal-3114242500-matrix { + .terminal-398211359-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3114242500-title { + .terminal-398211359-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3114242500-r1 { fill: #0000ff } - .terminal-3114242500-r2 { fill: #c5c8c6 } - .terminal-3114242500-r3 { fill: #ddeedd } + .terminal-398211359-r1 { fill: #0000ff } + .terminal-398211359-r2 { fill: #c5c8c6 } + .terminal-398211359-r3 { fill: #ddeedd } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VisibilityApp + VisibilityApp - - - - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃Widget 1 - - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - - - - - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃Widget 3 - - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - - - - - - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Widget 1 + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Widget 3 + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + + + @@ -4621,134 +4621,134 @@ font-weight: 700; } - .terminal-133545376-matrix { + .terminal-1865244878-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-133545376-title { + .terminal-1865244878-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-133545376-r1 { fill: #dde6ed;font-weight: bold } - .terminal-133545376-r2 { fill: #e1e1e1 } - .terminal-133545376-r3 { fill: #c5c8c6 } - .terminal-133545376-r4 { fill: #e7e5e2 } - .terminal-133545376-r5 { fill: #211505 } + .terminal-1865244878-r1 { fill: #dde6ed;font-weight: bold } + .terminal-1865244878-r2 { fill: #e1e1e1 } + .terminal-1865244878-r3 { fill: #c5c8c6 } + .terminal-1865244878-r4 { fill: #e7e5e2 } + .terminal-1865244878-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - -  lane  swimmer               country        time   -  4     Joseph Schooling      Singapore      50.39  -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  -  6     László Cseh           Hungary        51.14  -  3     Li Zhuhao             China          51.26  -  8     Mehdy Metella         France         51.58  -  7     Tom Shields           United States  51.73  -  1     Aleksandr Sadovnikov  Russia         51.84  - - - - - - - - - - - - - - + + + + laneswimmercountrytime + 4Joseph SchoolingSingapore50.39 +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  + 6László CsehHungary51.14 + 3Li ZhuhaoChina51.26 + 8Mehdy MetellaFrance51.58 + 7Tom ShieldsUnited States51.73 + 1Aleksandr SadovnikovRussia51.84 + + + + + + + + + + + + + + @@ -4779,169 +4779,169 @@ font-weight: 700; } - .terminal-746150767-matrix { + .terminal-4040833233-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-746150767-title { + .terminal-4040833233-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-746150767-r1 { fill: #c5c8c6 } - .terminal-746150767-r2 { fill: #e3e3e3 } - .terminal-746150767-r3 { fill: #e1e1e1 } - .terminal-746150767-r4 { fill: #23568b } - .terminal-746150767-r5 { fill: #e2e2e2 } - .terminal-746150767-r6 { fill: #004578 } - .terminal-746150767-r7 { fill: #14191f } - .terminal-746150767-r8 { fill: #262626 } - .terminal-746150767-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } - .terminal-746150767-r10 { fill: #e2e2e2;font-weight: bold } - .terminal-746150767-r11 { fill: #7ae998 } - .terminal-746150767-r12 { fill: #4ebf71;font-weight: bold } - .terminal-746150767-r13 { fill: #008139 } - .terminal-746150767-r14 { fill: #dde8f3;font-weight: bold } - .terminal-746150767-r15 { fill: #ddedf9 } + .terminal-4040833233-r1 { fill: #c5c8c6 } + .terminal-4040833233-r2 { fill: #e3e3e3 } + .terminal-4040833233-r3 { fill: #e1e1e1 } + .terminal-4040833233-r4 { fill: #23568b } + .terminal-4040833233-r5 { fill: #e2e2e2 } + .terminal-4040833233-r6 { fill: #004578 } + .terminal-4040833233-r7 { fill: #14191f } + .terminal-4040833233-r8 { fill: #262626 } + .terminal-4040833233-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } + .terminal-4040833233-r10 { fill: #e2e2e2;font-weight: bold } + .terminal-4040833233-r11 { fill: #7ae998 } + .terminal-4040833233-r12 { fill: #4ebf71;font-weight: bold } + .terminal-4040833233-r13 { fill: #008139 } + .terminal-4040833233-r14 { fill: #dde8f3;font-weight: bold } + .terminal-4040833233-r15 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Demo + Textual Demo - - - - Textual Demo - ▅▅ - - TOP - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▃▃ - - Widgets - Textual Demo - - Welcome! Textual is a framework for creating sophisticated - Rich contentapplications with the terminal.                            - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Start  - CSS▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - - - -                           Widgets                            -  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  + + + + Textual Demo + ▅▅ + + TOP + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▃▃ + + Widgets + Textual Demo + + Welcome! Textual is a framework for creating sophisticated + Rich contentapplications with the terminal. + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Start + CSS▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + + + +                           Widgets                            +  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  @@ -5285,133 +5285,133 @@ font-weight: 700; } - .terminal-2391317464-matrix { + .terminal-230484307-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2391317464-title { + .terminal-230484307-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2391317464-r1 { fill: #ffffff } - .terminal-2391317464-r2 { fill: #c5c8c6 } - .terminal-2391317464-r3 { fill: #e2e2e2 } + .terminal-230484307-r1 { fill: #ffffff } + .terminal-230484307-r2 { fill: #c5c8c6 } + .terminal-230484307-r3 { fill: #e2e2e2 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - FRApp + FRApp - - - - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - HEADER - - - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - ┏━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━┓ - foobarbaz - - - - - - - - - - - - ┗━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━┛ - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - FOOTER - - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + HEADER + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + foobarbaz + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + FOOTER + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -5441,133 +5441,133 @@ font-weight: 700; } - .terminal-2808014621-matrix { + .terminal-3077119198-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2808014621-title { + .terminal-3077119198-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2808014621-r1 { fill: #008000 } - .terminal-2808014621-r2 { fill: #c5c8c6 } - .terminal-2808014621-r3 { fill: #e1e1e1 } + .terminal-3077119198-r1 { fill: #008000 } + .terminal-3077119198-r2 { fill: #c5c8c6 } + .terminal-3077119198-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - GridLayoutExample + GridLayoutExample - - - - ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ - OneTwoThree - - - - - - - - - - └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ - ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ - FourFiveSix - - - - - - - - - - └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ + + + + ────────────────────────────────────────────────────────────────────────── + OneTwoThree + + + + + + + + + + ────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────── + FourFiveSix + + + + + + + + + + ────────────────────────────────────────────────────────────────────────── @@ -5597,133 +5597,133 @@ font-weight: 700; } - .terminal-947520513-matrix { + .terminal-1958232742-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-947520513-title { + .terminal-1958232742-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-947520513-r1 { fill: #008000 } - .terminal-947520513-r2 { fill: #c5c8c6 } - .terminal-947520513-r3 { fill: #e1e1e1 } + .terminal-1958232742-r1 { fill: #008000 } + .terminal-1958232742-r2 { fill: #c5c8c6 } + .terminal-1958232742-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - GridLayoutExample + GridLayoutExample - - - - ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ - OneTwoThree - - - - - - └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ - ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ - FourFiveSix - - - - - - └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ - ┌────────────────────────┐ - Seven - - - - - - └────────────────────────┘ + + + + ────────────────────────────────────────────────────────────────────────── + OneTwoThree + + + + + + ────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────── + FourFiveSix + + + + + + ────────────────────────────────────────────────────────────────────────── + ──────────────────────── + Seven + + + + + + ──────────────────────── @@ -5909,132 +5909,132 @@ font-weight: 700; } - .terminal-1066078378-matrix { + .terminal-4077214022-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1066078378-title { + .terminal-4077214022-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1066078378-r1 { fill: #c5c8c6 } - .terminal-1066078378-r2 { fill: #e3e3e3 } - .terminal-1066078378-r3 { fill: #e1e1e1 } + .terminal-4077214022-r1 { fill: #c5c8c6 } + .terminal-4077214022-r2 { fill: #e3e3e3 } + .terminal-4077214022-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeaderApp + HeaderApp - - - - HeaderApp - - - - - - - - - - - - - - - - - - - - - - + + + + HeaderApp + + + + + + + + + + + + + + + + + + + + + + @@ -6065,133 +6065,133 @@ font-weight: 700; } - .terminal-543913381-matrix { + .terminal-1769115774-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-543913381-title { + .terminal-1769115774-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-543913381-r1 { fill: #008000 } - .terminal-543913381-r2 { fill: #c5c8c6 } - .terminal-543913381-r3 { fill: #e1e1e1 } + .terminal-1769115774-r1 { fill: #008000 } + .terminal-1769115774-r2 { fill: #c5c8c6 } + .terminal-1769115774-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HorizontalLayoutExample + HorizontalLayoutExample - - - - ┌────────────────────────┐┌─────────────────────────┐┌─────────────────────────┐ - OneTwoThree - - - - - - - - - - - - - - - - - - - - - - └────────────────────────┘└─────────────────────────┘└─────────────────────────┘ + + + + ────────────────────────────────────────────────────────────────────────── + OneTwoThree + + + + + + + + + + + + + + + + + + + + + + ────────────────────────────────────────────────────────────────────────── @@ -6379,135 +6379,135 @@ font-weight: 700; } - .terminal-4091889985-matrix { + .terminal-4205022328-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4091889985-title { + .terminal-4205022328-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4091889985-r1 { fill: #1e1e1e } - .terminal-4091889985-r2 { fill: #121212 } - .terminal-4091889985-r3 { fill: #c5c8c6 } - .terminal-4091889985-r4 { fill: #e2e2e2 } - .terminal-4091889985-r5 { fill: #0178d4 } - .terminal-4091889985-r6 { fill: #e1e1e1 } + .terminal-4205022328-r1 { fill: #1e1e1e } + .terminal-4205022328-r2 { fill: #121212 } + .terminal-4205022328-r3 { fill: #c5c8c6 } + .terminal-4205022328-r4 { fill: #e2e2e2 } + .terminal-4205022328-r5 { fill: #0178d4 } + .terminal-4205022328-r6 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputApp + InputApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Darren                                                                     - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Burns - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Darren  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Burns + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -7166,135 +7166,135 @@ font-weight: 700; } - .terminal-2940916684-matrix { + .terminal-3700945997-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2940916684-title { + .terminal-3700945997-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2940916684-r1 { fill: #90ee90 } - .terminal-2940916684-r2 { fill: #c5c8c6 } - .terminal-2940916684-r3 { fill: #add8e6 } - .terminal-2940916684-r4 { fill: #808080 } - .terminal-2940916684-r5 { fill: #dddddd } - .terminal-2940916684-r6 { fill: #ffdddd } + .terminal-3700945997-r1 { fill: #90ee90 } + .terminal-3700945997-r2 { fill: #c5c8c6 } + .terminal-3700945997-r3 { fill: #add8e6 } + .terminal-3700945997-r4 { fill: #808080 } + .terminal-3700945997-r5 { fill: #dddddd } + .terminal-3700945997-r6 { fill: #ffdddd } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - NestedAutoApp + NestedAutoApp - - - - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┏━━━━━━━━━━━━━━━┓ - ┏━━━━━━━━━━━━━┓ - JUST ONE LINE - ┗━━━━━━━━━━━━━┛ - ┗━━━━━━━━━━━━━━━┛ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - - - - - - - - - - - - - - - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━ + JUST ONE LINE + ━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + + + + + + + + + + + + @@ -7325,133 +7325,133 @@ font-weight: 700; } - .terminal-1996000257-matrix { + .terminal-4150241775-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1996000257-title { + .terminal-4150241775-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1996000257-r1 { fill: #e1e1e1 } - .terminal-1996000257-r2 { fill: #c5c8c6 } - .terminal-1996000257-r3 { fill: #ffffff } - .terminal-1996000257-r4 { fill: #ddddef } + .terminal-4150241775-r1 { fill: #e1e1e1 } + .terminal-4150241775-r2 { fill: #c5c8c6 } + .terminal-4150241775-r3 { fill: #ffffff } + .terminal-4150241775-r4 { fill: #ddddef } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OffsetsApp + OffsetsApp - - - - - - - - - ┌──────────────┐ - FOO - BAR - BAZ - └──────────────┘ - - - - - - ┌──────────────┐ - FOO - BAR - BAZ - └──────────────┘ - - - + + + + + + + + + ────────────── + FOO + BAR + BAZ + ────────────── + + + + + + ────────────── + FOO + BAR + BAZ + ────────────── + + + @@ -7482,136 +7482,136 @@ font-weight: 700; } - .terminal-3751523503-matrix { + .terminal-1392305496-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3751523503-title { + .terminal-1392305496-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3751523503-r1 { fill: #ffff00 } - .terminal-3751523503-r2 { fill: #e3e3e3 } - .terminal-3751523503-r3 { fill: #c5c8c6 } - .terminal-3751523503-r4 { fill: #e1e1e1 } - .terminal-3751523503-r5 { fill: #dde8f3;font-weight: bold } - .terminal-3751523503-r6 { fill: #ddedf9 } + .terminal-1392305496-r1 { fill: #ffff00 } + .terminal-1392305496-r2 { fill: #e3e3e3 } + .terminal-1392305496-r3 { fill: #c5c8c6 } + .terminal-1392305496-r4 { fill: #e1e1e1 } + .terminal-1392305496-r5 { fill: #dde8f3;font-weight: bold } + .terminal-1392305496-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Layers + Layers - - - - ┌──────────────────────────────────┐Layers - It's full of stars! My God! It's full of sta - - This should float over the top - - - └──────────────────────────────────┘ - - - - - - - - - - - - - - - - -  T  Toggle Screen  + + + + ──────────────────────────────────Layers + It's full of stars! My God! It's full of sta + + This should float over the top + + + ────────────────────────────────── + + + + + + + + + + + + + + + + +  T  Toggle Screen  @@ -7641,136 +7641,136 @@ font-weight: 700; } - .terminal-3245245315-matrix { + .terminal-4137661484-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3245245315-title { + .terminal-4137661484-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3245245315-r1 { fill: #ffff00 } - .terminal-3245245315-r2 { fill: #c5c8c6 } - .terminal-3245245315-r3 { fill: #e3e3e3 } - .terminal-3245245315-r4 { fill: #ddeedd } - .terminal-3245245315-r5 { fill: #dde8f3;font-weight: bold } - .terminal-3245245315-r6 { fill: #ddedf9 } + .terminal-4137661484-r1 { fill: #ffff00 } + .terminal-4137661484-r2 { fill: #c5c8c6 } + .terminal-4137661484-r3 { fill: #e3e3e3 } + .terminal-4137661484-r4 { fill: #ddeedd } + .terminal-4137661484-r5 { fill: #dde8f3;font-weight: bold } + .terminal-4137661484-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Layers + Layers - - - - ┌──────────────────────────────────┐ - It's full of stars! My God! It's full of sta - - This should float over the top - - - └──────────────────────────────────┘ - - - - - - - - - - - - - - - - -  T  Toggle Screen  + + + + ────────────────────────────────── + It's full of stars! My God! It's full of sta + + This should float over the top + + + ────────────────────────────────── + + + + + + + + + + + + + + + + +  T  Toggle Screen  @@ -7969,131 +7969,131 @@ font-weight: 700; } - .terminal-2820474000-matrix { + .terminal-727043961-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2820474000-title { + .terminal-727043961-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2820474000-r1 { fill: #e1e1e1 } - .terminal-2820474000-r2 { fill: #c5c8c6 } + .terminal-727043961-r1 { fill: #e1e1e1 } + .terminal-727043961-r2 { fill: #c5c8c6 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TextLogLines + TextLogLines - - - - Key press #3                                                                   - Key press #4                                                                   - Key press #5                                                                   - - - - - - - - - - - - - - - - - - - - + + + + Key press #3 + Key press #4 + Key press #5 + + + + + + + + + + + + + + + + + + + + @@ -8124,132 +8124,132 @@ font-weight: 700; } - .terminal-9236131-matrix { + .terminal-2744759648-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-9236131-title { + .terminal-2744759648-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-9236131-r1 { fill: #e2e3e3 } - .terminal-9236131-r2 { fill: #c5c8c6 } - .terminal-9236131-r3 { fill: #008139 } + .terminal-2744759648-r1 { fill: #e2e3e3 } + .terminal-2744759648-r2 { fill: #c5c8c6 } + .terminal-2744759648-r3 { fill: #008139 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TreeApp + TreeApp - - - - ▼ Dune - └── ▼ Characters -     ├── Paul -     ├── Jessica -     └── Chani - - - - - - - - - - - - - - - - - - + + + + ▼ Dune + └── ▼ Characters + ├── Paul + ├── Jessica + └── Chani + + + + + + + + + + + + + + + + + + @@ -8280,133 +8280,133 @@ font-weight: 700; } - .terminal-660754755-matrix { + .terminal-452684828-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-660754755-title { + .terminal-452684828-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-660754755-r1 { fill: #008000 } - .terminal-660754755-r2 { fill: #c5c8c6 } - .terminal-660754755-r3 { fill: #e1e1e1 } + .terminal-452684828-r1 { fill: #008000 } + .terminal-452684828-r2 { fill: #c5c8c6 } + .terminal-452684828-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VerticalLayoutExample + VerticalLayoutExample - - - - ┌──────────────────────────────────────────────────────────────────────────────┐ - One - - - - - - └──────────────────────────────────────────────────────────────────────────────┘ - ┌──────────────────────────────────────────────────────────────────────────────┐ - Two - - - - - - └──────────────────────────────────────────────────────────────────────────────┘ - ┌──────────────────────────────────────────────────────────────────────────────┐ - Three - - - - - - └──────────────────────────────────────────────────────────────────────────────┘ + + + + ────────────────────────────────────────────────────────────────────────────── + One + + + + + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + Two + + + + + + ────────────────────────────────────────────────────────────────────────────── + ────────────────────────────────────────────────────────────────────────────── + Three + + + + + + ────────────────────────────────────────────────────────────────────────────── @@ -8436,134 +8436,134 @@ font-weight: 700; } - .terminal-1057512867-matrix { + .terminal-4186799416-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1057512867-title { + .terminal-4186799416-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1057512867-r1 { fill: #e1e1e1 } - .terminal-1057512867-r2 { fill: #ff0000 } - .terminal-1057512867-r3 { fill: #c5c8c6 } - .terminal-1057512867-r4 { fill: #0000ff } + .terminal-4186799416-r1 { fill: #e1e1e1 } + .terminal-4186799416-r2 { fill: #ff0000 } + .terminal-4186799416-r3 { fill: #c5c8c6 } + .terminal-4186799416-r4 { fill: #0000ff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Visibility + Visibility - - - - ┌──────────────────────────────────────┐ - bar - ┌────────────────────────────────────┐┌────────────────────────────────────┐ - floatfloat - └────────────────────────────────────┘└────────────────────────────────────┘ - - - - - - - - - - - - - - - - - - - └──────────────────────────────────────┘ + + + + ────────────────────────────────────── + bar + ──────────────────────────────────────────────────────────────────────── + floatfloat + ──────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + + + ────────────────────────────────────── diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py index 32795dca0..6f65f003f 100644 --- a/tests/test_styles_cache.py +++ b/tests/test_styles_cache.py @@ -4,13 +4,14 @@ from rich.segment import Segment from rich.style import Style from textual._styles_cache import StylesCache -from textual._types import Lines +from textual._types import Strips from textual.color import Color from textual.css.styles import Styles from textual.geometry import Region, Size +from textual.strip import Strip -def _extract_content(lines: Lines): +def _extract_content(lines: Strips): """Extract the text content from lines.""" content = ["".join(segment.text for segment in line) for line in lines] return content @@ -44,10 +45,11 @@ def test_no_styles(): ) style = Style.from_color(bgcolor=Color.parse("green").rich_color) expected = [ - [Segment("foo", style)], - [Segment("bar", style)], - [Segment("baz", style)], + Strip([Segment("foo", style)], 3), + Strip([Segment("bar", style)], 3), + Strip([Segment("baz", style)], 3), ] + assert lines == expected From 22fc4adaeef55637450748b5d647ae4a6abcd51c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Dec 2022 18:15:51 +0000 Subject: [PATCH 02/40] tidy --- src/textual/_compositor.py | 24 +++++------------------- src/textual/strip.py | 7 +++---- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 7ca7c4251..aa3128760 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -641,7 +641,7 @@ class Compositor: """Get rendered widgets (lists of segments) in the composition. Returns: - Iterable[tuple[Region, Region, Lines]]: An iterable of , , and + Iterable[tuple[Region, Region, Strips]]: An iterable of , , and """ # If a renderable throws an error while rendering, the user likely doesn't care about the traceback # up to this point. @@ -687,21 +687,7 @@ class Compositor: _Region(delta_x, delta_y, new_width, new_height) ) - # @classmethod - # def _assemble_chops(cls, chops: list[dict[int, Strip | None]]) -> list[Strip]: - # """Combine chops in to lines.""" - - # [Strip.join(strips) for strips in chops] - - # from_iterable = chain.from_iterable - - # segment_lines: list[list[Segment]] = [ - # list(from_iterable(strip for strip in bucket.values() if strip is not None)) - # for bucket in chops - # ] - # return segment_lines - - def render(self, full: bool = False) -> RenderableType: + def render(self, full: bool = False) -> RenderableType | None: """Render a layout. Returns: @@ -772,9 +758,9 @@ class Compositor: cut_strips = list(strip.divide(relative_cuts)) # Since we are painting front to back, the first segments for a cut "wins" - for cut, segments in zip(final_cuts, cut_strips): + for cut, strip in zip(final_cuts, cut_strips): if chops_line[cut] is None: - chops_line[cut] = segments + chops_line[cut] = strip if full: render_strips = [Strip.join(chop.values()) for chop in chops] @@ -787,7 +773,7 @@ class Compositor: self, console: Console, options: ConsoleOptions ) -> RenderResult: if self._dirty_regions: - yield self.render() + yield self.render() or "" def update_widgets(self, widgets: set[Widget]) -> None: """Update a given widget in the composition. diff --git a/src/textual/strip.py b/src/textual/strip.py index 77078ebcc..c361abf9f 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -44,10 +44,9 @@ class Strip: add_segments = segments.append total_cell_length = 0 for strip in strips: - if strip is None: - continue - total_cell_length += strip.cell_length - add_segments(strip._segments) + if strip is not None: + total_cell_length += strip.cell_length + add_segments(strip._segments) strip = cls(chain.from_iterable(segments), total_cell_length) return strip From 280ad91004cdad066471f0e8cb89625043b37fa6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Dec 2022 18:21:27 +0000 Subject: [PATCH 03/40] simplify --- src/textual/_compositor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index aa3128760..0cc8121da 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -755,7 +755,7 @@ class Compositor: else: render_x = render_region.x relative_cuts = [cut - render_x for cut in final_cuts[1:]] - cut_strips = list(strip.divide(relative_cuts)) + cut_strips = strip.divide(relative_cuts) # Since we are painting front to back, the first segments for a cut "wins" for cut, strip in zip(final_cuts, cut_strips): From e7d86aca9e606fc3dcbc960f212e62ac6a0e579c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Dec 2022 18:25:52 +0000 Subject: [PATCH 04/40] fix types --- src/textual/_segment_tools.py | 4 ++-- src/textual/strip.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index abb783496..f08ae5ef9 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -130,7 +130,7 @@ def line_pad( def align_lines( - lines: Strips, + lines: list[list[Segment]], style: Style, size: Size, horizontal: AlignHorizontal, @@ -153,7 +153,7 @@ def align_lines( width, height = size shape_width, shape_height = Segment.get_shape(lines) - def blank_lines(count: int) -> Strips: + def blank_lines(count: int) -> list[list[Segment]]: return [[Segment(" " * width, style)]] * count top_blank_lines = bottom_blank_lines = 0 diff --git a/src/textual/strip.py b/src/textual/strip.py index c361abf9f..62b69c54a 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -56,6 +56,9 @@ class Strip: def __iter__(self) -> Iterator[Segment]: return iter(self._segments) + def __reversed__(self) -> Iterator[Segment]: + return reversed(self._segments) + def __len__(self) -> int: return len(self._segments) From 073885e6f359b6c49e6bb734e268cd123f6fa408 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Dec 2022 22:03:14 +0000 Subject: [PATCH 05/40] fix types --- src/textual/_compositor.py | 1 - src/textual/_styles_cache.py | 5 ++--- src/textual/widget.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 0cc8121da..02ba31cd0 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -119,7 +119,6 @@ class ChopsUpdate: last_y = self.spans[-1][0] _cell_len = cell_len - for y, x1, x2 in self.spans: line = chops[y] ends = chop_ends[y] diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index e99c289fd..a0e20ef85 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -256,7 +256,7 @@ class StylesCache: inner = from_color(bgcolor=(base_background + background).rich_color) outer = from_color(bgcolor=base_background.rich_color) - def post(segments: Iterable[Segment]) -> list[Segment]: + def post(segments: Iterable[Segment]) -> Iterable[Segment]: """Post process segments to apply opacity and tint. Args: @@ -271,8 +271,7 @@ class StylesCache: segments = Tint.process_segments(segments, styles.tint) if styles.opacity != 1.0: segments = _apply_opacity(segments, base_background, styles.opacity) - segments = list(segments) - return segments if isinstance(segments, list) else list(segments) + return segments line: Iterable[Segment] # Draw top or bottom borders (A) diff --git a/src/textual/widget.py b/src/textual/widget.py index 69b1b2afa..c9164b91e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -157,7 +157,7 @@ class RenderCache(NamedTuple): """Stores results of a previous render.""" size: Size - lines: Strips + lines: list[list[Segment]] class WidgetError(Exception): From 0bca0d9bca96fb74928490a4be63590f6f2af9e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Dec 2022 22:16:43 +0000 Subject: [PATCH 06/40] optimization --- src/textual/_compositor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 02ba31cd0..b55a8fcbc 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -13,7 +13,6 @@ without having to render the entire screen. from __future__ import annotations -from itertools import chain from operator import itemgetter from typing import TYPE_CHECKING, Iterable, NamedTuple, cast @@ -281,12 +280,11 @@ class Compositor: # i.e. if something is moved / deleted / added if screen not in self._dirty_regions: - crop_screen = screen.intersection changes = map.items() ^ old_map.items() regions = { region for region in ( - crop_screen(map_geometry.visible_region) + map_geometry.clip.intersection(map_geometry.region) for _, map_geometry in changes ) if region From 2719a22fe2b9f2a90ce598a59081d0d660064aeb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 10:03:31 +0000 Subject: [PATCH 07/40] docstrings for Strip --- src/textual/_filter.py | 4 +- src/textual/strip.py | 84 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/textual/_filter.py b/src/textual/_filter.py index 3857d6812..4b488738f 100644 --- a/src/textual/_filter.py +++ b/src/textual/_filter.py @@ -13,14 +13,14 @@ class LineFilter(ABC): """Base class for a line filter.""" @abstractmethod - def filter(self, segments: list[Segment]) -> list[Segment]: + def apply(self, segments: list[Segment]) -> list[Segment]: """Transform a list of segments.""" class Monochrome(LineFilter): """Convert all colors to monochrome.""" - def filter(self, segments: list[Segment]) -> list[Segment]: + def apply(self, segments: list[Segment]) -> list[Segment]: to_monochrome = self.to_monochrome _Segment = Segment return [ diff --git a/src/textual/strip.py b/src/textual/strip.py index 62b69c54a..954fc4930 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -1,29 +1,37 @@ from __future__ import annotations from itertools import chain -from typing import Iterator, Iterable, Sequence +from typing import Iterator, Iterable import rich.repr from rich.cells import cell_len, set_cell_size from rich.segment import Segment from rich.style import Style +from ._cache import LRUCache from ._filter import LineFilter -from ._segment_tools import line_crop - -from ._profile import timer @rich.repr.auto class Strip: + """Represents a 'strip' (horizontal line) of a Textual Widget. + + A Strip is like an immutable list of Segments. The immutability allows for effective caching. + + Args: + segments (Iterable[Segment]): An iterable of segments. + cell_length (int | None, optional): The cell length if known, or None to calculate on demand. Defaults to None. + """ + __slots__ = ["_segments", "_cell_length", "_divide_cache"] def __init__( self, segments: Iterable[Segment], cell_length: int | None = None ) -> None: + self._segments = list(segments) self._cell_length = cell_length - self._divide_cache: dict[tuple[int], list[Strip]] = {} + self._divide_cache: LRUCache[tuple[int], list[Strip]] = LRUCache(4) def __rich_repr__(self) -> rich.repr.Result: yield self._segments @@ -38,7 +46,15 @@ class Strip: return self._cell_length @classmethod - def join(cls, strips: Iterable[Strip | None]) -> Strip: + def join(cls, strips: Iterable[Strip]) -> Strip: + """Join a number of strips in to one. + + Args: + strips (Iterable[Strip]): An iterable of Strips. + + Returns: + Strip: A new combined strip. + """ segments: list[list[Segment]] = [] add_segments = segments.append @@ -67,7 +83,16 @@ class Strip: self._segments == strip._segments and self.cell_length == strip.cell_length ) - def adjust_line_length(self, cell_length: int, style: Style | None) -> Strip: + def adjust_cell_length(self, cell_length: int, style: Style | None) -> Strip: + """Adjust the cell length, possibly truncating or extending. + + Args: + cell_length (int): New desired cell length. + style (Style | None): Style when extending, or `None`. Defaults to `None`. + + Returns: + Strip: A new strip with the supplied cell length. + """ new_line: list[Segment] line = self._segments @@ -76,11 +101,13 @@ class Strip: _Segment = Segment if current_cell_length < cell_length: + # Cell length is larger, so padd with spaces. new_line = line + [ _Segment(" " * (cell_length - current_cell_length), style) ] elif current_cell_length > cell_length: + # Cell length is shorter so we need to truncate. new_line = [] append = new_line.append line_length = 0 @@ -95,11 +122,17 @@ class Strip: append(_Segment(text, segment_style)) break else: + # Strip is already the required cell length, so return self. return self return Strip(new_line, cell_length) def simplify(self) -> Strip: + """Simplify the segments (join segments with same style) + + Returns: + Strip: New strip. + """ line = Strip( Segment.simplify(self._segments), self._cell_length, @@ -107,9 +140,26 @@ class Strip: return line def apply_filter(self, filter: LineFilter) -> Strip: - return Strip(filter.filter(self._segments), self._cell_length) + """Apply a filter to all segments in the strip. + + Args: + filter (LineFilter): A line filter object. + + Returns: + Strip: A new Strip. + """ + return Strip(filter.apply(self._segments), self._cell_length) def style_links(self, link_id: str, link_style: Style) -> Strip: + """Apply a style to Segments with the given link_id. + + Args: + link_id (str): A link id. + link_style (Style): Style to apply. + + Returns: + Strip: New strip (or same Strip if no changes). + """ _Segment = Segment if not any( segment.style._link_id == link_id @@ -130,6 +180,15 @@ class Strip: return Strip(segments, self._cell_length) def crop(self, start: int, end: int) -> Strip: + """Crop a strip between two cell positions. + + Args: + start (int): The start cell position (inclusive). + end (int): The end cell position (exclusive). + + Returns: + Strip: A new Strip. + """ _cell_len = cell_len pos = 0 output_segments: list[Segment] = [] @@ -165,6 +224,14 @@ class Strip: return Strip(output_segments, end - start) def divide(self, cuts: Iterable[int]) -> list[Strip]: + """Divide the strip in to multiple smaller strips by cutting at given (cell) indices. + + Args: + cuts (Iterable[int]): An iterable of cell positions as ints. + + Returns: + list[Strip]: A new list of strips. + """ pos = 0 cache_key = tuple(cuts) @@ -179,5 +246,4 @@ class Strip: pos += cut self._divide_cache[cache_key] = strips - return strips From 3fd8fe221042ff008e33209785946ff82e26c6a9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 13:02:46 +0000 Subject: [PATCH 08/40] tests --- examples/wid.py | 36 +++++++++++ src/textual/_cache.py | 16 ++++- src/textual/strip.py | 70 +++++++++++--------- tests/test_strip.py | 145 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+), 31 deletions(-) create mode 100644 examples/wid.py create mode 100644 tests/test_strip.py diff --git a/examples/wid.py b/examples/wid.py new file mode 100644 index 000000000..4bfeb782a --- /dev/null +++ b/examples/wid.py @@ -0,0 +1,36 @@ +from textual.app import App, ComposeResult +from textual.widget import Widget +from textual.widgets import Button, Footer + + +class MyWidget(Widget): + def __init__(self): + self.button = Button() + super().__init__() + + def compose(self) -> ComposeResult: + yield self.button + + +class MyApp(App): + + BINDINGS = [("s", "show_button", "Show Button")] + + def __init__(self): + self.my_widget = None + super().__init__() + + def compose(self) -> ComposeResult: + yield Footer() + + async def action_show_button(self) -> None: + if self.my_widget is None: + self.my_widget = MyWidget() + else: + await self.my_widget.remove() + await self.mount(self.my_widget) + + +if __name__ == "__main__": + app = MyApp() + app.run() diff --git a/src/textual/_cache.py b/src/textual/_cache.py index d6b877d30..c4721a748 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -43,6 +43,8 @@ class LRUCache(Generic[CacheKey, CacheValue]): self._full = False self._head: list[object] = [] self._lock = Lock() + self.hits = 0 + self.misses = 0 super().__init__() @property @@ -60,6 +62,11 @@ class LRUCache(Generic[CacheKey, CacheValue]): def __len__(self) -> int: return len(self._cache) + def __repr__(self) -> str: + return ( + f" None: """Grow the maximum size to at least `maxsize` elements. @@ -135,6 +142,7 @@ class LRUCache(Generic[CacheKey, CacheValue]): """ link = self._cache.get(key) if link is None: + self.misses += 1 return default with self._lock: if link is not self._head: @@ -146,11 +154,14 @@ class LRUCache(Generic[CacheKey, CacheValue]): link[0] = head[0] link[1] = head self._head = head[0][1] = head[0] = link # type: ignore[index] - + self.hits += 1 return link[3] # type: ignore[return-value] def __getitem__(self, key: CacheKey) -> CacheValue: - link = self._cache[key] + link = self._cache.get(key) + if link is None: + self.misses += 1 + raise KeyError(key) with self._lock: if link is not self._head: link[0][1] = link[1] # type: ignore[index] @@ -159,6 +170,7 @@ class LRUCache(Generic[CacheKey, CacheValue]): link[0] = head[0] link[1] = head self._head = head[0][1] = head[0] = link # type: ignore[index] + self.hits += 1 return link[3] # type: ignore[return-value] def __contains__(self, key: CacheKey) -> bool: diff --git a/src/textual/strip.py b/src/textual/strip.py index 954fc4930..5df51a696 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -23,15 +23,20 @@ class Strip: cell_length (int | None, optional): The cell length if known, or None to calculate on demand. Defaults to None. """ - __slots__ = ["_segments", "_cell_length", "_divide_cache"] + __slots__ = [ + "_segments", + "_cell_length", + "_divide_cache", + "_crop_cache", + ] def __init__( self, segments: Iterable[Segment], cell_length: int | None = None ) -> None: - self._segments = list(segments) self._cell_length = cell_length - self._divide_cache: LRUCache[tuple[int], list[Strip]] = LRUCache(4) + self._divide_cache: LRUCache[tuple[int, ...], list[Strip]] = LRUCache(4) + self._crop_cache: LRUCache[tuple[int, int], Strip] = LRUCache(4) def __rich_repr__(self) -> rich.repr.Result: yield self._segments @@ -83,7 +88,7 @@ class Strip: self._segments == strip._segments and self.cell_length == strip.cell_length ) - def adjust_cell_length(self, cell_length: int, style: Style | None) -> Strip: + def adjust_cell_length(self, cell_length: int, style: Style | None = None) -> Strip: """Adjust the cell length, possibly truncating or extending. Args: @@ -101,7 +106,7 @@ class Strip: _Segment = Segment if current_cell_length < cell_length: - # Cell length is larger, so padd with spaces. + # Cell length is larger, so pad with spaces. new_line = line + [ _Segment(" " * (cell_length - current_cell_length), style) ] @@ -189,39 +194,46 @@ class Strip: Returns: Strip: A new Strip. """ + cache_key = (start, end) + cached = self._crop_cache.get(cache_key) + if cached is not None: + return cached _cell_len = cell_len pos = 0 output_segments: list[Segment] = [] add_segment = output_segments.append iter_segments = iter(self._segments) segment: Segment | None = None - for segment in iter_segments: - end_pos = pos + _cell_len(segment.text) - if end_pos > start: - segment = segment.split_cells(start - pos)[1] - break - pos = end_pos + if start > self.cell_length: + strip = Strip([], 0) else: - return Strip([], 0) + for segment in iter_segments: + end_pos = pos + _cell_len(segment.text) + if end_pos > start: + segment = segment.split_cells(start - pos)[1] + break + pos = end_pos - if end >= self.cell_length: - # The end crop is the end of the segments, so we can collect all remaining segments - if segment: - add_segment(segment) - output_segments.extend(iter_segments) - return Strip(output_segments, self.cell_length - start) - - pos = start - while segment is not None: - end_pos = pos + _cell_len(segment.text) - if end_pos < end: - add_segment(segment) + if end >= self.cell_length: + # The end crop is the end of the segments, so we can collect all remaining segments + if segment: + add_segment(segment) + output_segments.extend(iter_segments) + strip = Strip(output_segments, self.cell_length - start) else: - add_segment(segment.split_cells(end - pos)[0]) - break - pos = end_pos - segment = next(iter_segments, None) - return Strip(output_segments, end - start) + pos = start + while segment is not None: + end_pos = pos + _cell_len(segment.text) + if end_pos < end: + add_segment(segment) + else: + add_segment(segment.split_cells(end - pos)[0]) + break + pos = end_pos + segment = next(iter_segments, None) + strip = Strip(output_segments, end - start) + self._crop_cache[cache_key] = strip + return strip def divide(self, cuts: Iterable[int]) -> list[Strip]: """Divide the strip in to multiple smaller strips by cutting at given (cell) indices. diff --git a/tests/test_strip.py b/tests/test_strip.py new file mode 100644 index 000000000..891af9845 --- /dev/null +++ b/tests/test_strip.py @@ -0,0 +1,145 @@ +from rich.segment import Segment +from rich.style import Style + +from textual.strip import Strip +from textual._filter import Monochrome + + +def test_cell_length() -> None: + strip = Strip([Segment("foo"), Segment("💩"), Segment("bar")]) + assert strip._cell_length is None + assert strip.cell_length == 8 + assert strip._cell_length == 8 + + +def test_repr() -> None: + strip = Strip([Segment("foo")]) + assert repr(strip) == "Strip([Segment('foo')], 3)" + + +def test_join() -> None: + strip1 = Strip([Segment("foo")]) + strip2 = Strip([Segment("bar")]) + strip = Strip.join([strip1, strip2]) + assert len(strip) == 2 + assert strip.cell_length == 6 + assert list(strip) == [Segment("foo"), Segment("bar")] + + +def test_bool() -> None: + assert not Strip([]) + assert Strip([Segment("foo")]) + + +def test_iter() -> None: + assert list(Strip([])) == [] + assert list(Strip([Segment("foo")])) == [Segment("foo")] + assert list(Strip([Segment("foo"), Segment("bar")])) == [ + Segment("foo"), + Segment("bar"), + ] + + +def test_len(): + assert len(Strip([])) == 0 + assert len(Strip([Segment("foo")])) == 1 + assert len(Strip([Segment("foo"), Segment("bar")])) == 2 + + +def test_reversed(): + assert list(reversed(Strip([]))) == [] + assert list(reversed(Strip([Segment("foo")]))) == [Segment("foo")] + assert list(reversed(Strip([Segment("foo"), Segment("bar")]))) == [ + Segment("bar"), + Segment("foo"), + ] + + +def test_eq(): + assert Strip([]) == Strip([]) + assert Strip([Segment("foo")]) == Strip([Segment("foo")]) + assert Strip([Segment("foo")]) != Strip([Segment("bar")]) + + +def test_adjust_cell_length(): + + for repeat in range(3): + + assert Strip([]).adjust_cell_length(3) == Strip([Segment(" ")]) + assert Strip([Segment("f")]).adjust_cell_length(3) == Strip( + [Segment("f"), Segment(" ")] + ) + assert Strip([Segment("💩")]).adjust_cell_length(3) == Strip( + [Segment("💩"), Segment(" ")] + ) + + assert Strip([Segment("💩💩")]).adjust_cell_length(3) == Strip([Segment("💩 ")]) + assert Strip([Segment("💩💩")]).adjust_cell_length(4) == Strip([Segment("💩💩")]) + assert Strip([Segment("💩"), Segment("💩💩")]).adjust_cell_length(2) == Strip( + [Segment("💩")] + ) + assert Strip([Segment("💩"), Segment("💩💩")]).adjust_cell_length(4) == Strip( + [Segment("💩"), Segment("💩")] + ) + + +def test_simplify(): + assert Strip([Segment("foo"), Segment("bar")]).simplify() == Strip( + [Segment("foobar")] + ) + + +def test_apply_filter(): + strip = Strip([Segment("foo", Style.parse("red"))]) + expected = Strip([Segment("foo", Style.parse("#1b1b1b"))]) + print(repr(strip)) + print(repr(expected)) + assert strip.apply_filter(Monochrome()) == expected + + +def test_style_links(): + link_style = Style.on(click="clicked") + strip = Strip( + [ + Segment("foo"), + Segment("bar", link_style), + Segment("baz"), + ] + ) + hover_style = Style(underline=True) + new_strip = strip.style_links(link_style._link_id, hover_style) + expected = Strip( + [ + Segment("foo"), + Segment("bar", link_style + hover_style), + Segment("baz"), + ] + ) + assert new_strip == expected + + +def test_crop(): + + for repeat in range(3): + + assert Strip([Segment("foo")]).crop(0, 3) == Strip([Segment("foo")]) + assert Strip([Segment("foo")]).crop(0, 2) == Strip([Segment("fo")]) + assert Strip([Segment("foo")]).crop(0, 1) == Strip([Segment("f")]) + + assert Strip([Segment("foo")]).crop(1, 3) == Strip([Segment("oo")]) + assert Strip([Segment("foo")]).crop(1, 2) == Strip([Segment("o")]) + assert Strip([Segment("foo")]).crop(1, 1) == Strip([Segment("")]) + + assert Strip([Segment("foo💩"), Segment("b💩ar"), Segment("ba💩z")]).crop( + 1, 6 + ) == Strip([Segment("oo💩"), Segment("b")]) + + +def test_divide(): + + for repeat in range(3): + + assert Strip([Segment("foo")]).divide([1, 2]) == [ + Strip([Segment("f")]), + Strip([Segment("o")]), + ] From ac0acde81e62f53f2857de1800209ea435f3bcc2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 13:03:46 +0000 Subject: [PATCH 09/40] accidental check in --- examples/wid.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 examples/wid.py diff --git a/examples/wid.py b/examples/wid.py deleted file mode 100644 index 4bfeb782a..000000000 --- a/examples/wid.py +++ /dev/null @@ -1,36 +0,0 @@ -from textual.app import App, ComposeResult -from textual.widget import Widget -from textual.widgets import Button, Footer - - -class MyWidget(Widget): - def __init__(self): - self.button = Button() - super().__init__() - - def compose(self) -> ComposeResult: - yield self.button - - -class MyApp(App): - - BINDINGS = [("s", "show_button", "Show Button")] - - def __init__(self): - self.my_widget = None - super().__init__() - - def compose(self) -> ComposeResult: - yield Footer() - - async def action_show_button(self) -> None: - if self.my_widget is None: - self.my_widget = MyWidget() - else: - await self.my_widget.remove() - await self.mount(self.my_widget) - - -if __name__ == "__main__": - app = MyApp() - app.run() From a2f380a9472410d4d133df0c4a971d5fda193109 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 13:06:35 +0000 Subject: [PATCH 10/40] fix typing --- src/textual/_types.py | 1 + src/textual/widgets/_data_table.py | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/textual/_types.py b/src/textual/_types.py index 4df812b89..4aa68d2b5 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -30,4 +30,5 @@ class EventTarget(Protocol): Strips = List["Strip"] +SegmentLines = List[List["Segment"]] CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 6b8eb5f38..e0259fb05 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -14,7 +14,7 @@ from rich.text import Text, TextType from .. import events, messages from .._cache import LRUCache from .._segment_tools import line_crop -from .._types import Strips +from .._types import SegmentLines from ..geometry import Region, Size, Spacing, clamp from ..reactive import Reactive from ..render import measure @@ -207,10 +207,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.row_count = 0 self._y_offsets: list[tuple[int, int]] = [] self._row_render_cache: LRUCache[ - tuple[int, int, Style, int, int], tuple[Strips, Strips] + tuple[int, int, Style, int, int], tuple[SegmentLines, SegmentLines] ] self._row_render_cache = LRUCache(1000) - self._cell_render_cache: LRUCache[tuple[int, int, Style, bool, bool], Strips] + self._cell_render_cache: LRUCache[ + tuple[int, int, Style, bool, bool], SegmentLines + ] self._cell_render_cache = LRUCache(10000) self._line_cache: LRUCache[ tuple[int, int, int, int, int, int, Style], list[Segment] @@ -488,7 +490,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): base_style: Style, cursor_column: int = -1, hover_column: int = -1, - ) -> tuple[Strips, Strips]: + ) -> tuple[SegmentLines, SegmentLines]: """Render a row in to lines for each cell. Args: From 2024552e0f5395686e0b562d800a9b6d11b76e5d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 13:09:07 +0000 Subject: [PATCH 11/40] typing fix --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index e0259fb05..8627b4733 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -452,7 +452,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): width: int, cursor: bool = False, hover: bool = False, - ) -> Strips: + ) -> SegmentLines: """Render the given cell. Args: From 81bc2ea7fe34c81a211e7159145e2295d73c6aa4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 13:09:37 +0000 Subject: [PATCH 12/40] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7681b1462..06845b433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.9.0] - Unreleased + +### Added + +- Added textual.strip primitive + ## [0.8.1] - 2022-12-25 ### Fixed From 54992d2d910af5b343029a299b12c5966edeccb9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 15:29:05 +0000 Subject: [PATCH 13/40] Added FIFOCache --- CHANGELOG.md | 3 +- src/textual/_cache.py | 80 +++++++++++++++++++++++++++++++++++++++++++ src/textual/strip.py | 6 ++-- tests/test_cache.py | 29 ++++++++++++++-- 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06845b433..043bedf99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added textual.strip primitive +- Added textual.strip.Strip primitive +- Added textual._cache.FIFOCache ## [0.8.1] - 2022-12-25 diff --git a/src/textual/_cache.py b/src/textual/_cache.py index c4721a748..eafdcfa67 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -175,3 +175,83 @@ class LRUCache(Generic[CacheKey, CacheValue]): def __contains__(self, key: CacheKey) -> bool: return key in self._cache + + +class FIFOCache(Generic[CacheKey, CacheValue]): + """A simple cache that discards the least recently added key when full. + + This has a lower overhead than LRUCache, but won't manage a working set as efficiently. + It is most suitable for a cache with a relatively low maximum size that is not expected to + do many lookups. + + Args: + maxsize (int): Maximum size of the cache. + """ + + __slots__ = [ + "_maxsize", + "_cache", + "_lock", + "hits", + "misses", + ] + + def __init__(self, maxsize: int) -> None: + self._maxsize = maxsize + self._cache: dict[CacheKey, CacheValue] = {} + self._lock = Lock() + self.hits = 0 + self.misses = 0 + + def __bool__(self) -> bool: + return bool(self._cache) + + def __len__(self) -> int: + return len(self._cache) + + def __repr__(self) -> str: + return ( + f"" + ) + + def clear(self) -> None: + """Clear the cache.""" + self._cache.clear() + + def keys(self) -> KeysView[CacheKey]: + """Get cache keys.""" + # Mostly for tests + return self._cache.keys() + + def set(self, key: CacheKey, value: CacheValue) -> None: + with self._lock: + if key not in self._cache and len(self._cache) == self._maxsize: + self._cache.pop(next(iter(self._cache.keys()))) + self._cache[key] = value + + __setitem__ = set + + @overload + def get(self, key: CacheKey) -> CacheValue | None: + ... + + @overload + def get(self, key: CacheKey, default: DefaultValue) -> CacheValue | DefaultValue: + ... + + def get( + self, key: CacheKey, default: DefaultValue | None = None + ) -> CacheValue | DefaultValue | None: + return self._cache.get(key, default) + + def __getitem__(self, key: CacheKey) -> CacheValue: + try: + return self._cache[key] + except KeyError: + self.misses += 1 + raise KeyError(key) from None + finally: + self.hits += 1 + + def __container__(self, key: CacheKey) -> bool: + return key in self._cache diff --git a/src/textual/strip.py b/src/textual/strip.py index 5df51a696..8eeb57725 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -8,7 +8,7 @@ from rich.cells import cell_len, set_cell_size from rich.segment import Segment from rich.style import Style -from ._cache import LRUCache +from ._cache import FIFOCache from ._filter import LineFilter @@ -35,8 +35,8 @@ class Strip: ) -> None: self._segments = list(segments) self._cell_length = cell_length - self._divide_cache: LRUCache[tuple[int, ...], list[Strip]] = LRUCache(4) - self._crop_cache: LRUCache[tuple[int, int], Strip] = LRUCache(4) + self._divide_cache: FIFOCache[tuple[int, ...], list[Strip]] = FIFOCache(4) + self._crop_cache: FIFOCache[tuple[int, int], Strip] = FIFOCache(4) def __rich_repr__(self) -> rich.repr.Result: yield self._segments diff --git a/tests/test_cache.py b/tests/test_cache.py index aedcbfc40..e27a4924b 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import pytest -from textual._cache import LRUCache +from textual._cache import FIFOCache, LRUCache def test_lru_cache(): @@ -61,6 +61,7 @@ def test_lru_cache_get(): assert "egg" not in cache assert "eggegg" in cache + def test_lru_cache_maxsize(): cache = LRUCache(3) @@ -74,7 +75,7 @@ def test_lru_cache_maxsize(): assert cache.maxsize == 30, "Incorrect cache maxsize after setting it" # Add more than maxsize items to the cache and be sure - for spam in range(cache.maxsize+10): + for spam in range(cache.maxsize + 10): cache[f"spam{spam}"] = spam # Finally, check the cache is the max size we set. @@ -146,3 +147,27 @@ def test_lru_cache_len(keys: list[str], expected_len: int): for value, key in enumerate(keys): cache[key] = value assert len(cache) == expected_len + + +def test_fifo_cache(): + cache = FIFOCache(4) + assert not cache + cache["foo"] = 1 + assert cache + cache["bar"] = 2 + cache["baz"] = 3 + cache["egg"] = 4 + # Cache is full + assert list(cache.keys()) == ["foo", "bar", "baz", "egg"] + assert len(cache) == 4 + cache["Paul"] = 100 + assert list(cache.keys()) == ["bar", "baz", "egg", "Paul"] + assert len(cache) == 4 + assert cache["baz"] == 3 + assert cache["bar"] == 2 + cache["Chani"] = 101 + assert list(cache.keys()) == ["baz", "egg", "Paul", "Chani"] + assert len(cache) == 4 + cache.clear() + assert len(cache) == 0 + assert list(cache.keys()) == [] From 1815792f2ee96792ffca8df21f68b16feaa1258d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 15:33:34 +0000 Subject: [PATCH 14/40] fix repr --- src/textual/_cache.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index eafdcfa67..357e0c86d 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -210,9 +210,7 @@ class FIFOCache(Generic[CacheKey, CacheValue]): return len(self._cache) def __repr__(self) -> str: - return ( - f"" - ) + return f"" def clear(self) -> None: """Clear the cache.""" @@ -225,7 +223,7 @@ class FIFOCache(Generic[CacheKey, CacheValue]): def set(self, key: CacheKey, value: CacheValue) -> None: with self._lock: - if key not in self._cache and len(self._cache) == self._maxsize: + if key not in self._cache and len(self._cache) >= self._maxsize: self._cache.pop(next(iter(self._cache.keys()))) self._cache[key] = value From b4535c386c33a09b93a49d1217650ae2ceb30d6e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 15:34:37 +0000 Subject: [PATCH 15/40] docstrings --- src/textual/_cache.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 357e0c86d..1e1d46a07 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -222,6 +222,12 @@ class FIFOCache(Generic[CacheKey, CacheValue]): return self._cache.keys() def set(self, key: CacheKey, value: CacheValue) -> None: + """Set a value. + + Args: + key (CacheKey): Key. + value (CacheValue): Value. + """ with self._lock: if key not in self._cache and len(self._cache) >= self._maxsize: self._cache.pop(next(iter(self._cache.keys()))) @@ -240,6 +246,15 @@ class FIFOCache(Generic[CacheKey, CacheValue]): def get( self, key: CacheKey, default: DefaultValue | None = None ) -> CacheValue | DefaultValue | None: + """Get a value from the cache, or return a default if the key is not present. + + Args: + key (CacheKey): Key + default (Optional[DefaultValue], optional): Default to return if key is not present. Defaults to None. + + Returns: + Union[CacheValue, Optional[DefaultValue]]: Either the value or a default. + """ return self._cache.get(key, default) def __getitem__(self, key: CacheKey) -> CacheValue: From 8007c612d45429c7b0cc4314f4aaedccb78334b9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 15:36:14 +0000 Subject: [PATCH 16/40] simpler --- src/textual/_cache.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 1e1d46a07..4796cf13e 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -64,7 +64,7 @@ class LRUCache(Generic[CacheKey, CacheValue]): def __repr__(self) -> str: return ( - f" None: @@ -210,7 +210,9 @@ class FIFOCache(Generic[CacheKey, CacheValue]): return len(self._cache) def __repr__(self) -> str: - return f"" + return ( + f"" + ) def clear(self) -> None: """Clear the cache.""" From 692fc252495ceec0591705644715460710d48b13 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 15:42:31 +0000 Subject: [PATCH 17/40] use fifo for arrangement cache --- src/textual/widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index c9164b91e..e85908d45 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -37,7 +37,7 @@ from rich.text import Text from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange -from ._cache import LRUCache +from ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -252,7 +252,7 @@ class Widget(DOMNode): self._content_height_cache: tuple[object, int] = (None, 0) self._arrangement_cache_updates: int = -1 - self._arrangement_cache: LRUCache[Size, DockArrangeResult] = LRUCache(4) + self._arrangement_cache: FIFOCache[Size, DockArrangeResult] = FIFOCache(4) self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} From 7ecceddab9abf2ee81c5ea6a8bbbac40b37e1303 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 15:42:53 +0000 Subject: [PATCH 18/40] Removed import --- src/textual/widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index e85908d45..f70d1a17d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -43,7 +43,6 @@ from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache -from ._types import Strips from .actions import SkipAction from .await_remove import AwaitRemove from .binding import Binding From d37050c0010e16e20a43e6e49e697dbca5bb9f60 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Dec 2022 15:52:08 +0000 Subject: [PATCH 19/40] typing --- src/textual/strip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/strip.py b/src/textual/strip.py index 8eeb57725..3a805b01e 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -51,7 +51,7 @@ class Strip: return self._cell_length @classmethod - def join(cls, strips: Iterable[Strip]) -> Strip: + def join(cls, strips: Iterable[Strip | None]) -> Strip: """Join a number of strips in to one. Args: From aa57b095da0426b9109840eafbcd35902238adb8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 09:21:53 +0000 Subject: [PATCH 20/40] remove fifo cache from widget --- src/textual/widget.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index f70d1a17d..8d2da1810 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -37,7 +37,6 @@ from rich.text import Text from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange -from ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -250,8 +249,8 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache_updates: int = -1 - self._arrangement_cache: FIFOCache[Size, DockArrangeResult] = FIFOCache(4) + self._arrangement_cache_key: tuple[Size, int] = (Size(), -1) + self._cached_arrangement: DockArrangeResult | None = None self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -463,22 +462,23 @@ class Widget(DOMNode): """ assert self.is_container - if self._arrangement_cache_updates != self.children._updates: - self._arrangement_cache_updates = self.children._updates - self._arrangement_cache.clear() + cache_key = (size, self.children._updates) + if ( + self._arrangement_cache_key == cache_key + and self._cached_arrangement is not None + ): + return self._cached_arrangement - cached_arrangement = self._arrangement_cache.get(size, None) - if cached_arrangement is not None: - return cached_arrangement - - arrangement = self._arrangement_cache[size] = arrange( + self._arrangement_cache_key = cache_key + arrangement = self._cached_arrangement = arrange( self, self.children, size, self.screen.size ) + return arrangement def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" - self._arrangement_cache.clear() + self._cached_arrangement = None def _get_virtual_dom(self) -> Iterable[Widget]: """Get widgets not part of the DOM. From 3b7c60b49ec8e2a43fa719776fae6dcff720197f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 09:29:31 +0000 Subject: [PATCH 21/40] remove locks --- src/textual/_cache.py | 102 +++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 55 deletions(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 4796cf13e..c78ac912f 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -14,7 +14,6 @@ where the overhead of the cache is a small fraction of the total processing time from __future__ import annotations -from threading import Lock from typing import Dict, Generic, KeysView, TypeVar, overload CacheKey = TypeVar("CacheKey") @@ -42,7 +41,6 @@ class LRUCache(Generic[CacheKey, CacheValue]): self._cache: Dict[CacheKey, list[object]] = {} self._full = False self._head: list[object] = [] - self._lock = Lock() self.hits = 0 self.misses = 0 super().__init__() @@ -77,10 +75,9 @@ class LRUCache(Generic[CacheKey, CacheValue]): def clear(self) -> None: """Clear the cache.""" - with self._lock: - self._cache.clear() - self._full = False - self._head = [] + self._cache.clear() + self._full = False + self._head = [] def keys(self) -> KeysView[CacheKey]: """Get cache keys.""" @@ -94,29 +91,28 @@ class LRUCache(Generic[CacheKey, CacheValue]): key (CacheKey): Key. value (CacheValue): Value. """ - with self._lock: - link = self._cache.get(key) - if link is None: - head = self._head - if not head: - # First link references itself - self._head[:] = [head, head, key, value] - else: - # Add a new root to the beginning - self._head = [head[0], head, key, value] - # Updated references on previous root - head[0][1] = self._head # type: ignore[index] - head[0] = self._head - self._cache[key] = self._head + link = self._cache.get(key) + if link is None: + head = self._head + if not head: + # First link references itself + self._head[:] = [head, head, key, value] + else: + # Add a new root to the beginning + self._head = [head[0], head, key, value] + # Updated references on previous root + head[0][1] = self._head # type: ignore[index] + head[0] = self._head + self._cache[key] = self._head - if self._full or len(self._cache) > self._maxsize: - # Cache is full, we need to evict the oldest one - self._full = True - head = self._head - last = head[0] - last[0][1] = head # type: ignore[index] - head[0] = last[0] # type: ignore[index] - del self._cache[last[2]] # type: ignore[index] + if self._full or len(self._cache) > self._maxsize: + # Cache is full, we need to evict the oldest one + self._full = True + head = self._head + last = head[0] + last[0][1] = head # type: ignore[index] + head[0] = last[0] # type: ignore[index] + del self._cache[last[2]] # type: ignore[index] __setitem__ = set @@ -144,34 +140,32 @@ class LRUCache(Generic[CacheKey, CacheValue]): if link is None: self.misses += 1 return default - with self._lock: - if link is not self._head: - # Remove link from list - link[0][1] = link[1] # type: ignore[index] - link[1][0] = link[0] # type: ignore[index] - head = self._head - # Move link to head of list - link[0] = head[0] - link[1] = head - self._head = head[0][1] = head[0] = link # type: ignore[index] - self.hits += 1 - return link[3] # type: ignore[return-value] + if link is not self._head: + # Remove link from list + link[0][1] = link[1] # type: ignore[index] + link[1][0] = link[0] # type: ignore[index] + head = self._head + # Move link to head of list + link[0] = head[0] + link[1] = head + self._head = head[0][1] = head[0] = link # type: ignore[index] + self.hits += 1 + return link[3] # type: ignore[return-value] def __getitem__(self, key: CacheKey) -> CacheValue: link = self._cache.get(key) if link is None: self.misses += 1 raise KeyError(key) - with self._lock: - if link is not self._head: - link[0][1] = link[1] # type: ignore[index] - link[1][0] = link[0] # type: ignore[index] - head = self._head - link[0] = head[0] - link[1] = head - self._head = head[0][1] = head[0] = link # type: ignore[index] - self.hits += 1 - return link[3] # type: ignore[return-value] + if link is not self._head: + link[0][1] = link[1] # type: ignore[index] + link[1][0] = link[0] # type: ignore[index] + head = self._head + link[0] = head[0] + link[1] = head + self._head = head[0][1] = head[0] = link # type: ignore[index] + self.hits += 1 + return link[3] # type: ignore[return-value] def __contains__(self, key: CacheKey) -> bool: return key in self._cache @@ -199,7 +193,6 @@ class FIFOCache(Generic[CacheKey, CacheValue]): def __init__(self, maxsize: int) -> None: self._maxsize = maxsize self._cache: dict[CacheKey, CacheValue] = {} - self._lock = Lock() self.hits = 0 self.misses = 0 @@ -230,10 +223,9 @@ class FIFOCache(Generic[CacheKey, CacheValue]): key (CacheKey): Key. value (CacheValue): Value. """ - with self._lock: - if key not in self._cache and len(self._cache) >= self._maxsize: - self._cache.pop(next(iter(self._cache.keys()))) - self._cache[key] = value + if key not in self._cache and len(self._cache) >= self._maxsize: + self._cache.pop(next(iter(self._cache.keys()))) + self._cache[key] = value __setitem__ = set From f0ddf6368cc49f6e2f1103964aab0a3873d0f5de Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 11:41:48 +0000 Subject: [PATCH 22/40] fix changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49248f31c..b0057fe76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -309,6 +309,9 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.8.2]: https://github.com/Textualize/textual/compare/v0.8.1...v0.8.2 +[0.8.1]: https://github.com/Textualize/textual/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/Textualize/textual/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/Textualize/textual/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/Textualize/textual/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/Textualize/textual/compare/v0.4.0...v0.5.0 From f102fcb8291642f8fcea5b2ca5079a090d783337 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 15:20:10 +0000 Subject: [PATCH 23/40] fix slots --- src/textual/_cache.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index c78ac912f..de4e96286 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -36,6 +36,15 @@ class LRUCache(Generic[CacheKey, CacheValue]): """ + __slots__ = [ + "_maxsize", + "_cache", + "_full", + "_head", + "hits", + "misses", + ] + def __init__(self, maxsize: int) -> None: self._maxsize = maxsize self._cache: Dict[CacheKey, list[object]] = {} @@ -172,7 +181,7 @@ class LRUCache(Generic[CacheKey, CacheValue]): class FIFOCache(Generic[CacheKey, CacheValue]): - """A simple cache that discards the least recently added key when full. + """A simple cache that discards the first added key when full (First In First Out). This has a lower overhead than LRUCache, but won't manage a working set as efficiently. It is most suitable for a cache with a relatively low maximum size that is not expected to @@ -185,7 +194,6 @@ class FIFOCache(Generic[CacheKey, CacheValue]): __slots__ = [ "_maxsize", "_cache", - "_lock", "hits", "misses", ] From 331a0ce65d90a033d7c816fbdca1a16320e723e2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 15:40:00 +0000 Subject: [PATCH 24/40] moar tests --- src/textual/_cache.py | 18 +++++++++++++----- tests/test_cache.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index de4e96286..3127bd22d 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -14,7 +14,7 @@ where the overhead of the cache is a small fraction of the total processing time from __future__ import annotations -from typing import Dict, Generic, KeysView, TypeVar, overload +from typing import cast, Dict, Generic, KeysView, TypeVar, overload CacheKey = TypeVar("CacheKey") CacheValue = TypeVar("CacheValue") @@ -257,16 +257,24 @@ class FIFOCache(Generic[CacheKey, CacheValue]): Returns: Union[CacheValue, Optional[DefaultValue]]: Either the value or a default. """ - return self._cache.get(key, default) + try: + result = self._cache[key] + except KeyError: + self.misses += 1 + return default + else: + self.hits += 1 + return result def __getitem__(self, key: CacheKey) -> CacheValue: try: - return self._cache[key] + result = self._cache[key] except KeyError: self.misses += 1 raise KeyError(key) from None - finally: + else: self.hits += 1 + return result - def __container__(self, key: CacheKey) -> bool: + def __contains__(self, key: CacheKey) -> bool: return key in self._cache diff --git a/tests/test_cache.py b/tests/test_cache.py index e27a4924b..9a4bb2ef3 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -152,7 +152,9 @@ def test_lru_cache_len(keys: list[str], expected_len: int): def test_fifo_cache(): cache = FIFOCache(4) assert not cache + assert "foo" not in cache cache["foo"] = 1 + assert "foo" in cache assert cache cache["bar"] = 2 cache["baz"] = 3 @@ -171,3 +173,35 @@ def test_fifo_cache(): cache.clear() assert len(cache) == 0 assert list(cache.keys()) == [] + + +def test_fifo_cache_hits(): + cache = FIFOCache(4) + assert cache.hits == 0 + assert cache.misses == 0 + + try: + cache["foo"] + except KeyError: + assert cache.hits == 0 + assert cache.misses == 1 + + cache["foo"] = 1 + assert cache.hits == 0 + assert cache.misses == 1 + + cache["foo"] + cache["foo"] + + assert cache.hits == 2 + assert cache.misses == 1 + + cache.get("bar") + assert cache.hits == 2 + assert cache.misses == 2 + + cache.get("foo") + assert cache.hits == 3 + assert cache.misses == 2 + + assert str(cache) == "" From 8113ff8705da4debab7638e305a8a7cedd9b1e3a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 15:44:56 +0000 Subject: [PATCH 25/40] moar tests --- src/textual/_cache.py | 4 ++-- tests/test_cache.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 3127bd22d..2698c4228 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -14,7 +14,7 @@ where the overhead of the cache is a small fraction of the total processing time from __future__ import annotations -from typing import cast, Dict, Generic, KeysView, TypeVar, overload +from typing import Dict, Generic, KeysView, TypeVar, overload CacheKey = TypeVar("CacheKey") CacheValue = TypeVar("CacheValue") @@ -71,7 +71,7 @@ class LRUCache(Generic[CacheKey, CacheValue]): def __repr__(self) -> str: return ( - f"" ) def grow(self, maxsize: int) -> None: diff --git a/tests/test_cache.py b/tests/test_cache.py index 9a4bb2ef3..86d7139f0 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -9,6 +9,8 @@ from textual._cache import FIFOCache, LRUCache def test_lru_cache(): cache = LRUCache(3) + assert str(cache) == "" + # insert some values cache["foo"] = 1 cache["bar"] = 2 @@ -35,6 +37,38 @@ def test_lru_cache(): assert "eggegg" in cache +def test_lru_cache_hits(): + cache = LRUCache(4) + assert cache.hits == 0 + assert cache.misses == 0 + + try: + cache["foo"] + except KeyError: + assert cache.hits == 0 + assert cache.misses == 1 + + cache["foo"] = 1 + assert cache.hits == 0 + assert cache.misses == 1 + + cache["foo"] + cache["foo"] + + assert cache.hits == 2 + assert cache.misses == 1 + + cache.get("bar") + assert cache.hits == 2 + assert cache.misses == 2 + + cache.get("foo") + assert cache.hits == 3 + assert cache.misses == 2 + + assert str(cache) == "" + + def test_lru_cache_get(): cache = LRUCache(3) From 716db2c0d661f1d1315900b9477efc6b89d04538 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 15:50:51 +0000 Subject: [PATCH 26/40] Added all --- src/textual/_cache.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 2698c4228..da53c6ad9 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -20,6 +20,8 @@ CacheKey = TypeVar("CacheKey") CacheValue = TypeVar("CacheValue") DefaultValue = TypeVar("DefaultValue") +__all__ = ["LRUCache", "FIFOCache"] + class LRUCache(Generic[CacheKey, CacheValue]): """ From 5382f5eb492cb72b67a5ad9043e4c2eb44515740 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 15:53:58 +0000 Subject: [PATCH 27/40] check len --- tests/test_cache.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cache.py b/tests/test_cache.py index 86d7139f0..1bf589755 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -185,10 +185,12 @@ def test_lru_cache_len(keys: list[str], expected_len: int): def test_fifo_cache(): cache = FIFOCache(4) + assert len(cache) == 0 assert not cache assert "foo" not in cache cache["foo"] = 1 assert "foo" in cache + assert len(cache) == 1 assert cache cache["bar"] = 2 cache["baz"] = 3 From 51b5ebe34721a336a3643bd04d593b5bd1639a7a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 21:10:14 +0000 Subject: [PATCH 28/40] don't need call to keys --- src/textual/_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index da53c6ad9..661eac09d 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -234,7 +234,7 @@ class FIFOCache(Generic[CacheKey, CacheValue]): value (CacheValue): Value. """ if key not in self._cache and len(self._cache) >= self._maxsize: - self._cache.pop(next(iter(self._cache.keys()))) + self._cache.pop(next(iter(self._cache))) self._cache[key] = value __setitem__ = set From 6091a91f96db6ab950cff1e59abe66b848d2a317 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Dec 2022 21:26:58 +0000 Subject: [PATCH 29/40] micro optimization --- src/textual/_cache.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/_cache.py b/src/textual/_cache.py index 661eac09d..a58c3b750 100644 --- a/src/textual/_cache.py +++ b/src/textual/_cache.py @@ -234,7 +234,9 @@ class FIFOCache(Generic[CacheKey, CacheValue]): value (CacheValue): Value. """ if key not in self._cache and len(self._cache) >= self._maxsize: - self._cache.pop(next(iter(self._cache))) + for first_key in self._cache: + self._cache.pop(first_key) + break self._cache[key] = value __setitem__ = set From cce244ddd0c333b72b281f3c5a34ce7049109eac Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 08:50:15 +0000 Subject: [PATCH 30/40] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc5d3801..a8a2fd459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -310,6 +310,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.9.0]: https://github.com/Textualize/textual/compare/v0.8.2...v0.9.0 [0.8.2]: https://github.com/Textualize/textual/compare/v0.8.1...v0.8.2 [0.8.1]: https://github.com/Textualize/textual/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/Textualize/textual/compare/v0.7.0...v0.8.0 From 51c7fef2e1a63133384f0aff1b4911de89595c43 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 10:13:37 +0000 Subject: [PATCH 31/40] extend trips to line API --- src/textual/_compositor.py | 5 ++--- src/textual/_segment_tools.py | 1 - src/textual/_styles_cache.py | 1 - src/textual/_types.py | 1 - src/textual/strip.py | 28 +++++++++++++++++++++++ src/textual/widget.py | 10 ++++----- src/textual/widgets/_data_table.py | 18 +++++++-------- src/textual/widgets/_text_log.py | 30 +++++++++++++------------ src/textual/widgets/_tree.py | 36 +++++++++++++----------------- tests/test_styles_cache.py | 3 +-- 10 files changed, 76 insertions(+), 57 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index b55a8fcbc..1e7ab1603 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,6 @@ from . import errors from ._cells import cell_len from ._loop import loop_last from .strip import Strip -from ._types import Strips from ._typing import TypeAlias from .geometry import NULL_OFFSET, Offset, Region, Size @@ -67,7 +66,7 @@ CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" class LayoutUpdate: """A renderable containing the result of a render for a given region.""" - def __init__(self, strips: Strips, region: Region) -> None: + def __init__(self, strips: list[Strip], region: Region) -> None: self.strips = strips self.region = region @@ -634,7 +633,7 @@ class Compositor: def _get_renders( self, crop: Region | None = None - ) -> Iterable[tuple[Region, Region, Strips]]: + ) -> Iterable[tuple[Region, Region, list[Strip]]]: """Get rendered widgets (lists of segments) in the composition. Returns: diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index f08ae5ef9..c12b06cbc 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -10,7 +10,6 @@ from rich.segment import Segment from rich.style import Style from ._cells import cell_len -from ._types import Strips from .css.types import AlignHorizontal, AlignVertical from .geometry import Size diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index a0e20ef85..737fa1527 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -11,7 +11,6 @@ from ._border import get_box, render_row from ._filter import LineFilter from ._opacity import _apply_opacity from ._segment_tools import line_crop, line_pad, line_trim -from ._types import Strips from ._typing import TypeAlias from .color import Color from .geometry import Region, Size, Spacing diff --git a/src/textual/_types.py b/src/textual/_types.py index 4aa68d2b5..52b2390cf 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -29,6 +29,5 @@ class EventTarget(Protocol): ... -Strips = List["Strip"] SegmentLines = List[List["Segment"]] CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]] diff --git a/src/textual/strip.py b/src/textual/strip.py index 3a805b01e..871602bce 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -42,6 +42,32 @@ class Strip: yield self._segments yield self.cell_length + @classmethod + def blank(cls, cell_length: int, style: Style | None) -> Strip: + """Create a blank strip. + + Args: + cell_length (int): Desired cell length. + style (Style | None): Style of blank. + + Returns: + Strip: New strip. + """ + return cls([Segment(" " * cell_length, style)], cell_length) + + @classmethod + def from_lines(cls, lines: list[list[Segment]], cell_length: int) -> list[Strip]: + """Convert lines (lists of segments) to a list of Strips. + + Args: + lines (list[list[Segment]]): List of lines, where a line is a list of segments. + cell_length (int): Cell length of lines (must be same). + + Returns: + list[Strip]: List of strips. + """ + return [cls(segments, cell_length) for segments in lines] + @property def cell_length(self) -> int: """Get the number of cells required to render this object.""" @@ -194,6 +220,8 @@ class Strip: Returns: Strip: A new Strip. """ + if start == 0 and end == self.cell_length: + return self cache_key = (start, end) cached = self._crop_cache.get(cache_key) if cached is not None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 8d2da1810..389c3b996 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -155,7 +155,7 @@ class RenderCache(NamedTuple): """Stores results of a previous render.""" size: Size - lines: list[list[Segment]] + lines: list[Strip] class WidgetError(Exception): @@ -2097,11 +2097,11 @@ class Widget(DOMNode): align_vertical, ) ) - - self._render_cache = RenderCache(self.size, lines) + strips = [Strip(line, width) for line in lines] + self._render_cache = RenderCache(self.size, strips) self._dirty_regions.clear() - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: """Render a line of content. Args: @@ -2115,7 +2115,7 @@ class Widget(DOMNode): try: line = self._render_cache.lines[y] except IndexError: - line = [Segment(" " * self.size.width, self.rich_style)] + line = Strip.blank(self.size.width, self.rich_style) return line def render_lines(self, crop: Region) -> list[Strip]: diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 732193a70..dd993fb36 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -19,6 +19,7 @@ from ..geometry import Region, Size, Spacing, clamp from ..reactive import Reactive from ..render import measure from ..scroll_view import ScrollView +from ..strip import Strip from .._typing import Literal CursorType = Literal["cell", "row", "column"] @@ -214,9 +215,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): tuple[int, int, Style, bool, bool], SegmentLines ] self._cell_render_cache = LRUCache(10000) - self._line_cache: LRUCache[ - tuple[int, int, int, int, int, int, Style], list[Segment] - ] + self._line_cache: LRUCache[tuple[int, int, int, int, int, int, Style], Strip] self._line_cache = LRUCache(1000) self._line_no = 0 @@ -567,9 +566,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): raise LookupError("Y coord {y!r} is greater than total height") return self._y_offsets[y] - def _render_line( - self, y: int, x1: int, x2: int, base_style: Style - ) -> list[Segment]: + def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: """Render a line in to a list of segments. Args: @@ -587,7 +584,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): try: row_index, line_no = self._get_offsets(y) except LookupError: - return [Segment(" " * width, base_style)] + return Strip.blank(width, base_style) cursor_column = ( self.cursor_column if (self.show_cursor and self.cursor_row == row_index) @@ -617,10 +614,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): segments = Segment.adjust_line_length(segments, width, style=base_style) simplified_segments = list(Segment.simplify(segments)) - self._line_cache[cache_key] = simplified_segments - return segments + strip = Strip(simplified_segments, width) + self._line_cache[cache_key] = strip + return strip - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: width, height = self.size scroll_x, scroll_y = self.scroll_offset fixed_top_row_count = sum( diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index 0e7df91c1..0bfc8b899 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -15,7 +15,7 @@ from ..geometry import Size, Region from ..scroll_view import ScrollView from .._cache import LRUCache from .._segment_tools import line_crop -from .._types import Strips +from ..strip import Strip class TextLog(ScrollView, can_focus=True): @@ -48,8 +48,8 @@ class TextLog(ScrollView, can_focus=True): super().__init__(name=name, id=id, classes=classes) self.max_lines = max_lines self._start_line: int = 0 - self.lines: list[list[Segment]] = [] - self._line_cache: LRUCache[tuple[int, int, int, int], list[Segment]] + self.lines: list[Strip] = [] + self._line_cache: LRUCache[tuple[int, int, int, int], Strip] self._line_cache = LRUCache(1024) self.max_width: int = 0 self.min_width = min_width @@ -120,7 +120,8 @@ class TextLog(ScrollView, can_focus=True): self.max_width, max(sum(segment.cell_length for segment in _line) for _line in lines), ) - self.lines.extend(lines) + strips = Strip.from_lines(lines, render_width) + self.lines.extend(strips) if self.max_lines is not None and len(self.lines) > self.max_lines: self._start_line += len(self.lines) - self.max_lines @@ -138,13 +139,13 @@ class TextLog(ScrollView, can_focus=True): self.virtual_size = Size(self.max_width, len(self.lines)) self.refresh() - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset line = self._render_line(scroll_y + y, scroll_x, self.size.width) - line = list(Segment.apply_style(line, self.rich_style)) - return line + strip = Strip(Segment.apply_style(line, self.rich_style), self.size.width) + return strip - def render_lines(self, crop: Region) -> Strips: + def render_lines(self, crop: Region) -> list[Strip]: """Render the widget in to lines. Args: @@ -156,19 +157,20 @@ class TextLog(ScrollView, can_focus=True): lines = self._styles_cache.render_widget(self, crop) return lines - def _render_line(self, y: int, scroll_x: int, width: int) -> list[Segment]: + def _render_line(self, y: int, scroll_x: int, width: int) -> Strip: if y >= len(self.lines): - return [Segment(" " * width, self.rich_style)] + return Strip.blank(width, self.rich_style) key = (y + self._start_line, scroll_x, width, self.max_width) if key in self._line_cache: return self._line_cache[key] - line = self.lines[y] - line = Segment.adjust_line_length( - line, max(self.max_width, width), self.rich_style + line = ( + self.lines[y] + .adjust_cell_length(max(self.max_width, width), self.rich_style) + .crop(scroll_x, scroll_x + width) ) - line = line_crop(line, scroll_x, scroll_x + width, self.max_width) + self._line_cache[key] = line return line diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 981911b75..e23385502 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -5,22 +5,21 @@ from typing import ClassVar, Generic, NewType, TypeVar import rich.repr from rich.segment import Segment -from rich.style import Style, NULL_STYLE +from rich.style import NULL_STYLE, Style from rich.text import Text, TextType - -from ..binding import Binding -from ..geometry import clamp, Region, Size -from .._loop import loop_last +from .. import events from .._cache import LRUCache -from ..message import Message -from ..reactive import reactive, var +from .._loop import loop_last from .._segment_tools import line_crop, line_pad from .._types import MessageTarget from .._typing import TypeAlias +from ..binding import Binding +from ..geometry import Region, Size, clamp +from ..message import Message +from ..reactive import reactive, var from ..scroll_view import ScrollView - -from .. import events +from ..strip import Strip NodeID = NewType("NodeID", int) TreeDataType = TypeVar("TreeDataType") @@ -365,7 +364,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._current_id = 0 self.root = self._add_node(None, text_label, data) - self._line_cache: LRUCache[LineCacheKey, list[Segment]] = LRUCache(1024) + self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024) self._tree_lines_cached: list[_TreeLine] | None = None self._cursor_node: TreeNode[TreeDataType] | None = None @@ -666,7 +665,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self.cursor_line = -1 self.refresh() - def render_line(self, y: int) -> list[Segment]: + def render_line(self, y: int) -> Strip: width = self.size.width scroll_x, scroll_y = self.scroll_offset style = self.rich_style @@ -677,14 +676,12 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): style, ) - def _render_line( - self, y: int, x1: int, x2: int, base_style: Style - ) -> list[Segment]: + def _render_line(self, y: int, x1: int, x2: int, base_style: Style) -> Strip: tree_lines = self._tree_lines width = self.size.width if y >= len(tree_lines): - return [Segment(" " * width, base_style)] + return Strip.blank(width, base_style) line = tree_lines[y] @@ -699,7 +696,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): tuple(node._updates for node in line.path), ) if cache_key in self._line_cache: - segments = self._line_cache[cache_key] + strip = self._line_cache[cache_key] else: base_guide_style = self.get_component_rich_style( "tree--guides", partial=True @@ -785,11 +782,10 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): segments = list(guides.render(self.app.console)) pad_width = max(self.virtual_size.width, width) segments = line_pad(segments, 0, pad_width - guides.cell_len, line_style) - self._line_cache[cache_key] = segments + strip = self._line_cache[cache_key] = Strip(segments) - segments = line_crop(segments, x1, x2, Segment.get_line_length(segments)) - - return segments + strip = strip.crop(x1, x2) + return strip def _on_resize(self, event: events.Resize) -> None: self._line_cache.grow(event.size.height) diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py index 6f65f003f..fdeb72dd6 100644 --- a/tests/test_styles_cache.py +++ b/tests/test_styles_cache.py @@ -4,14 +4,13 @@ from rich.segment import Segment from rich.style import Style from textual._styles_cache import StylesCache -from textual._types import Strips from textual.color import Color from textual.css.styles import Styles from textual.geometry import Region, Size from textual.strip import Strip -def _extract_content(lines: Strips): +def _extract_content(lines: list[list[Segment]]): """Extract the text content from lines.""" content = ["".join(segment.text for segment in line) for line in lines] return content From d6451b52aa405658c5fd084a4f94004531acaf2a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 10:15:53 +0000 Subject: [PATCH 32/40] changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a2fd459..bcada54cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added textual.strip.Strip primitive - Added textual._cache.FIFOCache - Added an option to clear columns in DataTable.clear() https://github.com/Textualize/textual/pull/1427 + +### Changed + +- Widget.render_line now returns a Strip ## [0.8.2] - 2022-12-28 From f13e8e793b67e0c727c3da45de7ed30c9bcbde3f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 10:20:08 +0000 Subject: [PATCH 33/40] simplify with strip --- src/textual/widgets/_data_table.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index dd993fb36..667b7ca01 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -611,10 +611,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): scrollable_line: list[Segment] = list(chain.from_iterable(scrollable)) segments = fixed_line + line_crop(scrollable_line, x1 + fixed_width, x2, width) - segments = Segment.adjust_line_length(segments, width, style=base_style) - simplified_segments = list(Segment.simplify(segments)) + strip = Strip(segments).adjust_cell_length(width, base_style).simplify() - strip = Strip(simplified_segments, width) self._line_cache[cache_key] = strip return strip From 57654a90bf2daad0143c391385d1f82561fcb928 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 10:23:34 +0000 Subject: [PATCH 34/40] fix snapshot --- .../__snapshots__/test_snapshots.ambr | 116 +++++++++--------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index a67872a63..4a9464a81 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -4621,134 +4621,134 @@ font-weight: 700; } - .terminal-1865244878-matrix { + .terminal-133545376-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1865244878-title { + .terminal-133545376-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1865244878-r1 { fill: #dde6ed;font-weight: bold } - .terminal-1865244878-r2 { fill: #e1e1e1 } - .terminal-1865244878-r3 { fill: #c5c8c6 } - .terminal-1865244878-r4 { fill: #e7e5e2 } - .terminal-1865244878-r5 { fill: #211505 } + .terminal-133545376-r1 { fill: #dde6ed;font-weight: bold } + .terminal-133545376-r2 { fill: #e1e1e1 } + .terminal-133545376-r3 { fill: #c5c8c6 } + .terminal-133545376-r4 { fill: #e7e5e2 } + .terminal-133545376-r5 { fill: #211505 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableApp + TableApp - - - - laneswimmercountrytime - 4Joseph SchoolingSingapore50.39 -  2     Michael Phelps        United States  51.14  -  5     Chad le Clos          South Africa   51.14  - 6László CsehHungary51.14 - 3Li ZhuhaoChina51.26 - 8Mehdy MetellaFrance51.58 - 7Tom ShieldsUnited States51.73 - 1Aleksandr SadovnikovRussia51.84 - - - - - - - - - - - - - - + + + +  lane  swimmer               country        time   +  4     Joseph Schooling      Singapore      50.39  +  2     Michael Phelps        United States  51.14  +  5     Chad le Clos          South Africa   51.14  +  6     László Cseh           Hungary        51.14  +  3     Li Zhuhao             China          51.26  +  8     Mehdy Metella         France         51.58  +  7     Tom Shields           United States  51.73  +  1     Aleksandr Sadovnikov  Russia         51.84  + + + + + + + + + + + + + + From 17ef3284f7833326a709edbfbb330e932c367f57 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 11:01:35 +0000 Subject: [PATCH 35/40] sort imports --- src/textual/strip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/strip.py b/src/textual/strip.py index 871602bce..7a83d5e40 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -1,7 +1,7 @@ from __future__ import annotations from itertools import chain -from typing import Iterator, Iterable +from typing import Iterable, Iterator import rich.repr from rich.cells import cell_len, set_cell_size From 38858e493fa79326c30d87b8c413e87ff8165973 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 17:49:25 -0800 Subject: [PATCH 36/40] timer update --- CHANGELOG.md | 1 + src/textual/_time.py | 11 +++++++++++ src/textual/timer.py | 8 ++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcada54cd..c31f51108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Widget.render_line now returns a Strip +- Fix for slow updates on Windows ## [0.8.2] - 2022-12-28 diff --git a/src/textual/_time.py b/src/textual/_time.py index f99abc138..701331e1a 100644 --- a/src/textual/_time.py +++ b/src/textual/_time.py @@ -1,5 +1,6 @@ import platform +from asyncio import sleep as asleep from time import monotonic, perf_counter PLATFORM = platform.system() @@ -10,3 +11,13 @@ if WINDOWS: time = perf_counter else: time = monotonic + + +if WINDOWS: + async def sleep(sleep_for:float) -> None: + start = time() + while time() - start < sleep_for - 1/1000: + await asleep(0) + +else: + sleep = asleep diff --git a/src/textual/timer.py b/src/textual/timer.py index eb98a9533..4614cbe52 100644 --- a/src/textual/timer.py +++ b/src/textual/timer.py @@ -16,6 +16,7 @@ from asyncio import ( ) from typing import Awaitable, Callable, Union + from rich.repr import Result, rich_repr from . import events @@ -23,6 +24,7 @@ from ._callback import invoke from ._context import active_app from . import _clock from ._types import MessageTarget +from ._time import sleep TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] @@ -140,6 +142,7 @@ class Timer: _interval = self._interval await self._active.wait() start = _clock.get_time_no_wait() + sleep_event = Event() while _repeat is None or count <= _repeat: next_timer = start + ((count + 1) * _interval) now = await _clock.get_time() @@ -148,8 +151,9 @@ class Timer: continue now = await _clock.get_time() wait_time = max(0, next_timer - now) - if wait_time: - await _clock.sleep(wait_time) + if wait_time > 1/1000: + await sleep(wait_time) + count += 1 await self._active.wait() From 85582a409351b90efbf32ea04941f86866545fec Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 17:56:18 +0000 Subject: [PATCH 37/40] timer fix for Windoze --- src/textual/_time.py | 14 +++++++++++--- src/textual/timer.py | 19 ++++++------------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/textual/_time.py b/src/textual/_time.py index 701331e1a..4d8437c3c 100644 --- a/src/textual/_time.py +++ b/src/textual/_time.py @@ -14,10 +14,18 @@ else: if WINDOWS: - async def sleep(sleep_for:float) -> None: + + async def sleep(sleep_for: float) -> None: + """An asyncio sleep. + + On Windows this achieves a better granularity that asyncio.sleep(0) + + Args: + sleep_for (float): Seconds to sleep for. + """ start = time() - while time() - start < sleep_for - 1/1000: + while time() - start < sleep_for - 1 / 1000: await asleep(0) - + else: sleep = asleep diff --git a/src/textual/timer.py b/src/textual/timer.py index 4614cbe52..a6e90140c 100644 --- a/src/textual/timer.py +++ b/src/textual/timer.py @@ -9,22 +9,16 @@ from __future__ import annotations import asyncio import weakref -from asyncio import ( - CancelledError, - Event, - Task, -) +from asyncio import CancelledError, Event, Task from typing import Awaitable, Callable, Union - from rich.repr import Result, rich_repr -from . import events +from . import _clock, events from ._callback import invoke from ._context import active_app -from . import _clock -from ._types import MessageTarget from ._time import sleep +from ._types import MessageTarget TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] @@ -142,7 +136,7 @@ class Timer: _interval = self._interval await self._active.wait() start = _clock.get_time_no_wait() - sleep_event = Event() + while _repeat is None or count <= _repeat: next_timer = start + ((count + 1) * _interval) now = await _clock.get_time() @@ -151,9 +145,8 @@ class Timer: continue now = await _clock.get_time() wait_time = max(0, next_timer - now) - if wait_time > 1/1000: - await sleep(wait_time) - + if wait_time > 1 / 1000: + await sleep(wait_time) count += 1 await self._active.wait() From 7fa289a3328fb76b5bda1cd330f2c98407fc506f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 20:41:14 +0000 Subject: [PATCH 38/40] better sleep --- src/textual/_time.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/textual/_time.py b/src/textual/_time.py index 4d8437c3c..9b48dedfa 100644 --- a/src/textual/_time.py +++ b/src/textual/_time.py @@ -1,7 +1,8 @@ import platform -from asyncio import sleep as asleep -from time import monotonic, perf_counter +from asyncio import sleep as asyncio_sleep, get_running_loop +from time import monotonic, perf_counter, sleep as time_sleep + PLATFORM = platform.system() WINDOWS = PLATFORM == "Windows" @@ -23,9 +24,7 @@ if WINDOWS: Args: sleep_for (float): Seconds to sleep for. """ - start = time() - while time() - start < sleep_for - 1 / 1000: - await asleep(0) + await get_running_loop().run_in_executor(None, time_sleep, sleep_for) else: - sleep = asleep + sleep = asyncio_sleep From 51cfa23c27c373f06d806554845b499ab5e16e03 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 29 Dec 2022 21:43:15 +0000 Subject: [PATCH 39/40] docstring --- src/textual/_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_time.py b/src/textual/_time.py index 9b48dedfa..f33246946 100644 --- a/src/textual/_time.py +++ b/src/textual/_time.py @@ -19,7 +19,7 @@ if WINDOWS: async def sleep(sleep_for: float) -> None: """An asyncio sleep. - On Windows this achieves a better granularity that asyncio.sleep(0) + On Windows this achieves a better granularity that asyncio.sleep Args: sleep_for (float): Seconds to sleep for. From 734b742c6953e361c8cf9d7337797379819c11a4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 30 Dec 2022 09:33:31 +0000 Subject: [PATCH 40/40] Added link --- docs/blog/posts/better-sleep-on-windows.md | 54 ++++++++++++++++++ src/textual/_sleep.py | 65 ++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 docs/blog/posts/better-sleep-on-windows.md create mode 100644 src/textual/_sleep.py diff --git a/docs/blog/posts/better-sleep-on-windows.md b/docs/blog/posts/better-sleep-on-windows.md new file mode 100644 index 000000000..b0d3d23ba --- /dev/null +++ b/docs/blog/posts/better-sleep-on-windows.md @@ -0,0 +1,54 @@ +--- +draft: false +date: 2022-12-30 +categories: + - DevLog +authors: + - willmcgugan +--- +# A better asyncio sleep for Windows to fix animation + +I spent some time optimizing Textual on Windows recently, and discovered something which may be of interest to anyone working with async code on that platform. + + + +Animation, scrolling, and fading had always been unsatisfactory on Windows. Textual was usable, but the lag when scrolling made it a little unpleasant to use. On macOS and Linux, scrolling is fast enough that it feels close to a native app, and not something running in a terminal. Yet the Windows experience never improved, even as Textual got faster with each release. + +I had chalked this up to Windows Terminal being slow to render updates. After all, the classic Windows terminal was (and still is) glacially slow. Perhaps Microsoft just weren't focusing on performance. + +In retrospect, that was highly improbable. Like all modern terminals, Windows Terminal uses the GPU to render updates. Even without focussing on performance, it should be fast. + +I figured I'd give it once last attempt to speed up Textual on Windows. If I failed, Windows would forever be a third-class platform for Textual apps. + +It turned out that it was nothing to do with performance, per se. The issue was with a single asyncio function: `asyncio.sleep`. + +Textual has a `Timer` class which creates events at regular intervals. It powers the JS-like `set_interval` and `set_timer` functions. It is also used internally to do animation (such as smooth scrolling). This Timer class calls `asyncio.sleep` to wait the time between one event and the next. + +On macOS and Linux, calling `asynco.sleep` is fairly accurate. If you call `sleep(3.14)`, it will return within 1% of 3.14 seconds. This is not the case for Windows, which for historical reasons uses a timer with a granularity of 15 milliseconds. The upshot is that sleep times will be rounded up to the nearest multiple of 15 milliseconds. + +This limit appears holds true for all async primitives on Windows. If you wait for something with a timeout, it will return on a multiple of 15 milliseconds. Fortunately there is work in the CPython pipeline to make this more accurate. Thanks to [Steve Dower](https://twitter.com/zooba) for pointing this out. + +This lack of accuracy in the timer meant that timer events were created at a far slower rate that intended. Animation was slower because Textual was waiting too long between updates. + +Once I had figured that out, I needed an alternative to `asyncio.sleep` for Textual's Timer class. And I found one. The following version of `sleep` is accurate to well within 1%: + +```python +from time import sleep +from asyncio import get_running_loop + +async def sleep(sleep_for: float) -> None: + """An asyncio sleep. + + On Windows this achieves a better granularity that asyncio.sleep + + Args: + sleep_for (float): Seconds to sleep for. + """ + await get_running_loop().run_in_executor(None, time_sleep, sleep_for) +``` + +That is a drop-in replacement for sleep on Windows. With it, Textual runs a *lot* smoother. Easily on par with macOS and Linux. + +It's not quite perfect. There is a little *tearing* during full "screen" updates, but performance is decent all round. I suspect when [this bug]( https://bugs.python.org/issue37871) is fixed (big thanks to [Paul Moore](https://twitter.com/pf_moore) for looking in to that), and Microsoft implements [this protocol](https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036) then Textual on Windows will be A+. + +This Windows improvement will be in v0.9.0 of [Textual](https://github.com/Textualize/textual), which will be released in a few days. diff --git a/src/textual/_sleep.py b/src/textual/_sleep.py new file mode 100644 index 000000000..cb632e899 --- /dev/null +++ b/src/textual/_sleep.py @@ -0,0 +1,65 @@ +from __future__ import annotations + + +from time import sleep, perf_counter +from asyncio import get_running_loop +from threading import Thread, Event + + +class Sleeper(Thread): + def __init__( + self, + ) -> None: + self._exit = False + self._sleep_time = 0.0 + self._event = Event() + self.future = None + self._loop = get_running_loop() + super().__init__(daemon=True) + + def run(self): + while True: + self._event.wait() + if self._exit: + break + sleep(self._sleep_time) + self._event.clear() + # self.future.set_result(None) + self._loop.call_soon_threadsafe(self.future.set_result, None) + + async def sleep(self, sleep_time: float) -> None: + future = self.future = self._loop.create_future() + self._sleep_time = sleep_time + self._event.set() + await future + + # await self._async_event.wait() + # self._async_event.clear() + + +async def check_sleeps() -> None: + + sleeper = Sleeper() + sleeper.start() + + async def profile_sleep(sleep_for: float) -> float: + start = perf_counter() + + while perf_counter() - start < sleep_for: + sleep(0) + # await sleeper.sleep(sleep_for) + elapsed = perf_counter() - start + return elapsed + + for t in range(15, 120, 5): + sleep_time = 1 / t + elapsed = await profile_sleep(sleep_time) + difference = (elapsed / sleep_time * 100) - 100 + print( + f"sleep={sleep_time*1000:.01f}ms clock={elapsed*1000:.01f}ms diff={difference:.02f}%" + ) + + +from asyncio import run + +run(check_sleeps())