3939ContentType : TypeAlias = Union ["Content" , str ]
4040"""Type alias used where content and a str are interchangeable in a function."""
4141
42+ ContentText : TypeAlias = Union ["Content" , Text , str ]
43+ """A type that may be used to construct Text."""
44+
4245ANSI_DEFAULT = Style (
4346 background = Color (0 , 0 , 0 , 0 , ansi = - 1 ),
4447 foreground = Color (0 , 0 , 0 , 0 , ansi = - 1 ),
@@ -134,6 +137,8 @@ def __init__(
134137 self ._text : str = _strip_control_codes (text )
135138 self ._spans : list [Span ] = [] if spans is None else spans
136139 self ._cell_length = cell_length
140+ self ._optimal_width_cache : int | None = None
141+ self ._height_cache : tuple [tuple [int , str , bool ] | None , int ] = (None , 0 )
137142
138143 def __str__ (self ) -> str :
139144 return self ._text
@@ -168,6 +173,46 @@ def markup(self) -> str:
168173 markup = "" .join (output )
169174 return markup
170175
176+ @classmethod
177+ def empty (cls ) -> Content :
178+ """Get an empty (blank) content"""
179+ return EMPTY_CONTENT
180+
181+ @classmethod
182+ def from_text (
183+ cls , markup_content_or_text : ContentText , markup : bool = True
184+ ) -> Content :
185+ """Construct content from Text or str. If the argument is already Content, then
186+ return it unmodified.
187+
188+ This method exists to make (Rich) Text and Content interchangeable. While Content
189+ is preferred, we don't want to make it harder than necessary for apps to use Text.
190+
191+ Args:
192+ markup_content_or_text: Value to create Content from.
193+ markup: If `True`, then str values will be parsed as markup, otherwise they will
194+ be considered literals.
195+
196+ Raises:
197+ TypeError: If the supplied argument is not a valid type.
198+
199+ Returns:
200+ A new Content instance.
201+ """
202+ if isinstance (markup_content_or_text , Content ):
203+ return markup_content_or_text
204+ elif isinstance (markup_content_or_text , str ):
205+ if markup :
206+ return cls .from_markup (markup_content_or_text )
207+ else :
208+ return cls (markup_content_or_text )
209+ elif isinstance (markup_content_or_text , Text ):
210+ return cls .from_rich_text (markup_content_or_text )
211+ else :
212+ raise TypeError (
213+ "This method expects a str, a Text instance, or a Content instance"
214+ )
215+
171216 @classmethod
172217 def from_markup (cls , markup : str | Content , ** variables : object ) -> Content :
173218 """Create content from Textual markup, optionally combined with template variables.
@@ -208,6 +253,8 @@ def from_rich_text(
208253
209254 Args:
210255 text: String or Rich Text.
256+ console: A Console object to use if parsing Rich Console markup, or `None` to
257+ use app default.
211258
212259 Returns:
213260 New Content.
@@ -220,7 +267,12 @@ def from_rich_text(
220267 if console is not None :
221268 get_style = console .get_style
222269 else :
223- get_style = RichStyle .parse
270+ try :
271+ app = active_app .get ()
272+ except LookupError :
273+ get_style = RichStyle .parse
274+ else :
275+ get_style = app .console .get_style
224276
225277 if text ._spans :
226278 try :
@@ -280,7 +332,7 @@ def styled(
280332
281333 @classmethod
282334 def assemble (
283- cls , * parts : str | Content | tuple [str , str ], end : str = ""
335+ cls , * parts : str | Content | tuple [str , str | Style ], end : str = ""
284336 ) -> Content :
285337 """Construct new content from string, content, or tuples of (TEXT, STYLE).
286338
@@ -367,11 +419,7 @@ def is_same(self, content: Content) -> bool:
367419 return False
368420 return self .spans == content .spans
369421
370- def get_optimal_width (
371- self ,
372- rules : RulesMap ,
373- container_width : int ,
374- ) -> int :
422+ def get_optimal_width (self , rules : RulesMap , container_width : int ) -> int :
375423 """Get optimal width of the Visual to display its content.
376424
377425 The exact definition of "optimal width" is dependant on the Visual, but
@@ -380,14 +428,18 @@ def get_optimal_width(
380428
381429 Args:
382430 rules: A mapping of style rules, such as the Widgets `styles` object.
383- container_width: The size of the container in cells.
384431
385432 Returns:
386433 A width in cells.
387434
388435 """
389- width = max (cell_len (line ) for line in self .plain .split ("\n " ))
390- return width
436+ if self ._optimal_width_cache is None :
437+ self ._optimal_width_cache = width = max (
438+ cell_len (line ) for line in self .plain .split ("\n " )
439+ )
440+ else :
441+ width = self ._optimal_width_cache
442+ return width + rules .get ("line_pad" , 0 ) * 2
391443
392444 def get_height (self , rules : RulesMap , width : int ) -> int :
393445 """Get the height of the Visual if rendered at the given width.
@@ -399,22 +451,32 @@ def get_height(self, rules: RulesMap, width: int) -> int:
399451 Returns:
400452 A height in lines.
401453 """
402- lines = self .without_spans ._wrap_and_format (
403- width ,
404- overflow = rules .get ("text_overflow" , "fold" ),
405- no_wrap = rules .get ("text_wrap" , "wrap" ) == "nowrap" ,
406- )
407- return len (lines )
454+ get_rule = rules .get
455+ line_pad = get_rule ("line_pad" , 0 ) * 2
456+ overflow = get_rule ("text_overflow" , "fold" )
457+ no_wrap = get_rule ("text_wrap" , "wrap" ) == "nowrap"
458+ cache_key = (width + line_pad , overflow , no_wrap )
459+ if self ._height_cache [0 ] == cache_key :
460+ height = self ._height_cache [1 ]
461+ else :
462+ lines = self .without_spans ._wrap_and_format (
463+ width - line_pad , overflow = overflow , no_wrap = no_wrap
464+ )
465+ height = len (lines )
466+ self ._height_cache = (cache_key , height )
467+ return height
408468
409469 def _wrap_and_format (
410470 self ,
411471 width : int ,
412472 align : TextAlign = "left" ,
413473 overflow : TextOverflow = "fold" ,
414474 no_wrap : bool = False ,
475+ line_pad : int = 0 ,
415476 tab_size : int = 8 ,
416477 selection : Selection | None = None ,
417478 selection_style : Style | None = None ,
479+ post_style : Style | None = None ,
418480 ) -> list [_FormattedLine ]:
419481 """Wraps the text and applies formatting.
420482
@@ -440,6 +502,10 @@ def get_span(y: int) -> tuple[int, int] | None:
440502 return None
441503
442504 for y , line in enumerate (self .split (allow_blank = True )):
505+
506+ if post_style is not None :
507+ line = line .stylize (post_style )
508+
443509 if selection_style is not None and (span := get_span (y )) is not None :
444510 start , end = span
445511 if end == - 1 :
@@ -461,15 +527,27 @@ def get_span(y: int) -> tuple[int, int] | None:
461527 new_lines = [content_line ]
462528 else :
463529 content_line = _FormattedLine (line , width , y = y , align = align )
464- offsets = divide_line (line .plain , width , fold = overflow == "fold" )
530+ offsets = divide_line (
531+ line .plain , width - line_pad * 2 , fold = overflow == "fold"
532+ )
465533 divided_lines = content_line .content .divide (offsets )
534+ ellipsis = overflow == "ellipsis"
466535 divided_lines = [
467- line .truncate (width , ellipsis = overflow == "ellipsis" )
468- for line in divided_lines
536+ (
537+ line .truncate (width , ellipsis = ellipsis )
538+ if last
539+ else line .rstrip ().truncate (width , ellipsis = ellipsis )
540+ )
541+ for last , line in loop_last (divided_lines )
469542 ]
543+
470544 new_lines = [
471545 _FormattedLine (
472- content .rstrip_end (width ), width , offset , y , align = align
546+ content .rstrip_end (width ).pad (line_pad , line_pad ),
547+ width ,
548+ offset ,
549+ y ,
550+ align = align ,
473551 )
474552 for content , offset in zip (divided_lines , [0 , * offsets ])
475553 ]
@@ -487,6 +565,7 @@ def render_strips(
487565 style : Style ,
488566 selection : Selection | None = None ,
489567 selection_style : Style | None = None ,
568+ post_style : Style | None = None ,
490569 ) -> list [Strip ]:
491570 """Render the visual into an iterable of strips. Part of the Visual protocol.
492571
@@ -497,6 +576,7 @@ def render_strips(
497576 style: The base style to render on top of.
498577 selection: Selection information, if applicable, otherwise `None`.
499578 selection_style: Selection style if `selection` is not `None`.
579+ post_style: Style | None = None,
500580
501581 Returns:
502582 An list of Strips.
@@ -505,14 +585,17 @@ def render_strips(
505585 if not width :
506586 return []
507587
588+ get_rule = rules .get
508589 lines = self ._wrap_and_format (
509590 width ,
510- align = rules .get ("text_align" , "left" ),
511- overflow = rules .get ("text_overflow" , "fold" ),
512- no_wrap = rules .get ("text_wrap" , "wrap" ) == "nowrap" ,
591+ align = get_rule ("text_align" , "left" ),
592+ overflow = get_rule ("text_overflow" , "fold" ),
593+ no_wrap = get_rule ("text_wrap" , "wrap" ) == "nowrap" ,
594+ line_pad = get_rule ("line_pad" , 0 ),
513595 tab_size = 8 ,
514596 selection = selection ,
515597 selection_style = selection_style ,
598+ post_style = post_style ,
516599 )
517600
518601 if height is not None :
@@ -566,6 +649,13 @@ def without_spans(self) -> Content:
566649 """The content with no spans"""
567650 return Content (self .plain , [], self ._cell_length )
568651
652+ @property
653+ def first_line (self ) -> Content :
654+ """The first line of the content."""
655+ if "\n " not in self .plain :
656+ return self
657+ return self [: self .plain .index ("\n " )]
658+
569659 def __getitem__ (self , slice : int | slice ) -> Content :
570660 def get_text_at (offset : int ) -> "Content" :
571661 _Span = Span
@@ -837,6 +927,34 @@ def pad_right(self, count: int, character: str = " ") -> Content:
837927 )
838928 return self
839929
930+ def pad (self , left : int , right : int , character : str = " " ) -> Content :
931+ """Pad both the left and right edges with a given number of characters.
932+
933+ Args:
934+ left (int): Number of characters to pad on the left.
935+ right (int): Number of characters to pad on the right.
936+ character (str, optional): Character to pad with. Defaults to " ".
937+ """
938+ assert len (character ) == 1 , "Character must be a string of length 1"
939+ if left or right :
940+ text = f"{ character * left } { self .plain } { character * right } "
941+ _Span = Span
942+ if left :
943+ spans = [
944+ _Span (start + left , end + left , style )
945+ for start , end , style in self ._spans
946+ ]
947+ else :
948+ spans = self ._spans
949+ content = Content (
950+ text ,
951+ spans ,
952+ None if self ._cell_length is None else self ._cell_length + left + right ,
953+ )
954+ return content
955+
956+ return self
957+
840958 def center (self , width : int , ellipsis : bool = False ) -> Content :
841959 """Align a line to the center.
842960
@@ -850,7 +968,7 @@ def center(self, width: int, ellipsis: bool = False) -> Content:
850968 content = self .rstrip ().truncate (width , ellipsis = ellipsis )
851969 left = (width - content .cell_length ) // 2
852970 right = width - left
853- content = content .pad_left (left ). pad_right ( right )
971+ content = content .pad (left , right )
854972 return content
855973
856974 def right (self , width : int , ellipsis : bool = False ) -> Content :
@@ -1404,3 +1522,6 @@ def _apply_link_style(
14041522 if style is not None
14051523 ]
14061524 return segments
1525+
1526+
1527+ EMPTY_CONTENT : Final = Content ("" )
0 commit comments