File Coverage

blib/lib/String/Tagged/Terminal.pm
Criterion Covered Total %
statement 127 139 91.3
branch 99 120 82.5
condition 61 88 69.3
subroutine 16 18 88.8
pod 6 6 100.0
total 309 371 83.2


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2017-2024 -- leonerd@leonerd.org.uk
5              
6             package String::Tagged::Terminal 0.08;
7              
8 6     6   1549870 use v5.14;
  6         25  
9 6     6   40 use warnings;
  6         17  
  6         393  
10              
11 6     6   35 use base qw( String::Tagged );
  6         10  
  6         4340  
12              
13 6     6   86485 use Carp;
  6         19  
  6         493  
14              
15 6     6   37 use constant HAVE_MSWIN32 => $^O eq "MSWin32";
  6         9  
  6         14670  
16             HAVE_MSWIN32 and require String::Tagged::Terminal::Win32Console;
17              
18             require IO::Handle;
19              
20             =head1 NAME
21              
22             C - format terminal output using C
23              
24             =head1 SYNOPSIS
25              
26             use String::Tagged::Terminal;
27              
28             my $st = String::Tagged::Terminal->new
29             ->append( "Hello my name is " )
30             ->append_tagged( $name, bold => 1, fgindex => 4 );
31              
32             $st->say_to_terminal;
33              
34             =head1 DESCRIPTION
35              
36             This subclass of L provides a method, C,
37             for outputting the formatting tags embedded in the string as terminal escape
38             sequences, to render the the output in the appropriate style.
39              
40             =head1 TAGS
41              
42             The following tag names are recognised:
43              
44             =head2 bold, under, italic, strike, blink, reverse
45              
46             These tags take a boolean value. If the value is true then the corresponding
47             terminal rendering attribute is enabled.
48              
49             =head2 altfont
50              
51             This tag takes an integer value. If defined it uses the "alternate font
52             selection" sequence.
53              
54             =head2 fgindex, bgindex
55              
56             These tags take an integer value in the range 0 to 255. These select the
57             foreground or background colour by using VGA, high-brightness extended 16
58             colour, or xterm 256 palette mode attributes, depending on the value.
59              
60             The ECMA-48-corrected string encoding form of C is used to set
61             the 256 palette values.
62              
63             Values will be rounded down to the nearest integer by calling C. This
64             convenience allows things like the C function for generating random
65             colours:
66              
67             $st->append_tagged( "text", fgindex => 1 + rand 6 );
68              
69             =head2 sizepos
70              
71             I
72              
73             (experimental)
74              
75             This tag takes a value indicating an adjustment to the vertical positioning,
76             and possibly also size, in order to create subscript or superscript effects.
77              
78             Recognised values are C for subscript, and C for superscript.
79             These are implemented using the F-style C codes.
80              
81             =head2 link
82              
83             I
84              
85             (experimental)
86              
87             This tag takes a HASH reference, whose C key is emitted using the
88             C hyperlink sequence.
89              
90             =cut
91              
92             =head1 CONSTRUCTORS
93              
94             =cut
95              
96             =head2 new_from_formatting
97              
98             $st = String::Tagged::Terminal->new_from_formatting( $fmt )
99              
100             Returns a new instance by converting L standard
101             tags.
102              
103             Foreground and background colours are converted to their nearest index in the
104             xterm 256 colour palette. The C Formatting attribute is rendered by
105             selecting the first alternate font using C.
106              
107             =cut
108              
109             sub new_from_formatting
110             {
111 2     2 1 382408 my $class = shift;
112 2         8 my ( $orig ) = @_;
113              
114 2         26 require Convert::Color::XTerm;
115              
116             return $class->clone( $orig,
117             only_tags => [qw(
118             bold under italic strike blink reverse sizepos link
119             monospace
120             fg bg
121             )],
122             convert_tags => {
123 1 50   1   139 monospace => sub { $_[1] ? ( altfont => 1 ) : () },
124              
125 1     1   144 fg => sub { fgindex => $_[1]->as_xterm->index },
126 0     0   0 bg => sub { bgindex => $_[1]->as_xterm->index },
127             },
128 2         48 );
129             }
130              
131             =head2 parse_terminal
132              
133             $st = String::Tagged::Terminal->parse_terminal( $str );
134              
135             I
136              
137             Returns a new instance by parsing a string containing SGR terminal escape
138             sequences mixed with plain string content.
139              
140             The parser will only accept 7- or 8-bit encodings of the SGR escape sequence
141             (C<\e[ ... m> or C<\x9b ... m>). If any other escape sequences are present,
142             an exception is thrown.
143              
144             Conversely, unrecognised formatting codes in SGR sequences are simply ignored
145             without warning.
146              
147             =cut
148              
149             my $CSI_args = qr/[0-9;:]*/;
150              
151             sub parse_terminal
152             {
153 19     19 1 276493 my $class = shift;
154 19         47 my ( $s ) = @_;
155              
156 19         80 my $self = $class->new;
157              
158 19         1056 pos($s) = 0;
159              
160 19         50 my %tags;
161              
162 19         67 while( pos($s) < length($s) ) {
163 81 100 66     3435 if( $s =~ m/\G([^\e]+)/gc ) {
    100 66        
    100          
164 41         178 $self->append_tagged( $1, %tags );
165             }
166             elsif( $s =~ m/\G\e\[($CSI_args)m/gc || $s =~ m/\G\x9b($CSI_args)m/gc ) {
167 37         101 my $args = $1;
168 37 100       109 length $args or $args = "0";
169 37         125 foreach my $arg ( split m/;/, $args ) {
170 37         90 my ( $a0, @arest ) = map { int $_ } split m/:/, $arg;
  39         228  
171              
172             # Reset
173 37 100 100     336 if( $a0 == 0 ) { %tags = () }
  18 100 100     67  
    50 100        
    100 100        
    50 100        
    100 100        
    50 66        
    100 66        
    50          
    100          
    50          
    100          
    50          
    100          
    100          
    100          
    100          
    50          
174              
175             # Simple boolean attributes
176 3         16 elsif( $a0 == 1 ) { $tags{bold} = 1; }
177 0         0 elsif( $a0 == 22 ) { delete $tags{bold}; }
178 1         6 elsif( $a0 == 4 ) { $tags{under} = 1; }
179 0         0 elsif( $a0 == 24 ) { delete $tags{under}; }
180 1         5 elsif( $a0 == 3 ) { $tags{italic} = 1; }
181 0         0 elsif( $a0 == 23 ) { delete $tags{italic}; }
182 1         5 elsif( $a0 == 9 ) { $tags{strike} = 1; }
183 0         0 elsif( $a0 == 29 ) { delete $tags{strike}; }
184 1         6 elsif( $a0 == 5 ) { $tags{blink} = 1; }
185 0         0 elsif( $a0 == 25 ) { delete $tags{blink}; }
186 1         6 elsif( $a0 == 7 ) { $tags{reverse} = 1; }
187 0         0 elsif( $a0 == 27 ) { delete $tags{reverse}; }
188              
189             # Numerical attributes
190             elsif( $a0 >= 10 && $a0 <= 19 ) {
191 1 50       8 $a0 > 10 ? $tags{altfont} = $a0 - 10 : delete $tags{altfont};
192             }
193              
194             # Colours
195             elsif( $a0 >= 30 && $a0 <= 39 or $a0 >= 90 && $a0 <= 97 or
196             $a0 >= 40 && $a0 <= 49 or $a0 >= 100 && $a0 <= 107 ) {
197 7 100       19 my $hi = $a0 >= 90 ? 8 : 0; $a0 -= 60 if $hi;
  7 100       20  
198 7 100       19 my $attr = $a0 < 40 ? "fgindex" : "bgindex";
199 7         14 $a0 %= 10;
200              
201 7 50       21 if ( $a0 == 9 ) { delete $tags{$attr} }
  0 100       0  
202             elsif( $a0 == 8 ) {
203 1 50 33     9 if( @arest >= 2 and $arest[0] == 5 ) {
204 1         7 $tags{$attr} = $arest[1];
205             }
206             # Else unrecognised
207             }
208 6         30 else { $tags{$attr} = $a0 + $hi }
209             }
210              
211             # Sub/superscript
212 1         6 elsif( $a0 == 73 ) { $tags{sizepos} = "super"; }
213 1         5 elsif( $a0 == 74 ) { $tags{sizepos} = "sub"; }
214 0         0 elsif( $a0 == 75 ) { delete $tags{sizepos}; }
215              
216             # Else unrecognised
217             }
218             }
219             elsif( $s =~ m/\G\e]8;/gc || $s =~ m/\G\x{9d}8;/gc ) {
220             # OSC 8 hyperlink
221 2         9 $s =~ m/\G.*?;/gc; # skip args
222              
223 2 0 33     12 $s =~ m/\G(.*?)\e\\/gc or $s =~ m/\G(.*?)\x07/gc or $s =~ m/\G(.*?)\x9c/gc or
      33        
224             croak "Found an OSC 8 introduction that does not end with ST";
225              
226             length $1 ? $tags{link} = { uri => $1 }
227 2 100       15 : delete $tags{link};
228             }
229             else {
230 1         239 croak "Found an escape sequence that is not SGR";
231             }
232             }
233              
234 18         802 return $self;
235             }
236              
237             =head1 METHODS
238              
239             The following methods are provided in addition to those provided by
240             L.
241              
242             =cut
243              
244             =head2 build_terminal
245              
246             $str = $st->build_terminal( %opts );
247              
248             Returns a string containing terminal escape sequences mixed with string
249             content to render the string to a terminal.
250              
251             As this string will contain literal terminal control escape sequences, care
252             should be taken when passing it around, printing it for debugging purposes, or
253             similar.
254              
255             Takes the following additional named options:
256              
257             =over 4
258              
259             =item no_color
260              
261             If true, the C and C attributes will be ignored. This has
262             the result of performing some formatting using the other attributes, but not
263             setting colours.
264              
265             =back
266              
267             =cut
268              
269             sub build_terminal
270             {
271 32     32 1 285993 my $self = shift;
272 32         78 my %opts = @_;
273              
274 32         69 my $ret = "";
275 32         61 my %pen;
276             my $osc8_uri;
277             $self->iter_substr_nooverlap( sub {
278 77     77   5631 my ( $s, %tags ) = @_;
279              
280 77         139 my @sgr;
281              
282             # Simple boolean attributes first
283 77         428 foreach (
284             [ bold => 1, 22 ],
285             [ under => 4, 24 ],
286             [ italic => 3, 23 ],
287             [ strike => 9, 29 ],
288             [ blink => 5, 25 ],
289             [ reverse => 7, 27 ],
290             ) {
291 462         1011 my ( $tag, $on, $off ) = @$_;
292              
293 462 100 66     1247 push( @sgr, $on ), $pen{$tag} = 1 if $tags{$tag} and !$pen{$tag};
294 462 100 100     1900 push( @sgr, $off ), delete $pen{$tag} if !$tags{$tag} and $pen{$tag};
295             }
296              
297             # Numerical attributes
298 77         292 foreach (
299             [ altfont => 10, 9 ],
300             ) {
301 77         173 my ( $tag, $base, $max ) = @$_;
302              
303 77 100 66     456 if( defined $pen{$tag} and !defined $tags{$tag} ) {
    50 33        
    100 33        
304 3         7 push @sgr, $base;
305 3         10 delete $pen{$tag};
306             }
307             elsif( defined $pen{$tag} and defined $tags{$tag} and $pen{$tag} == $tags{$tag} ) {
308             # Leave it
309             }
310             elsif( defined $tags{$tag} ) {
311 3         7 my $val = $tags{$tag};
312 3 50       10 $val = $max if $val > $max;
313 3         8 push @sgr, $base + $val;
314 3         22 $pen{$tag} = $val;
315             }
316             }
317              
318             # Colour index attributes
319 77         242 foreach (
320             [ fgindex => 30 ],
321             [ bgindex => 40 ],
322             ) {
323 154         339 my ( $tag, $base ) = @$_;
324 154         261 my $val = $tags{$tag};
325 154 100       355 $val = int $val if defined $val;
326              
327 154 100 100     792 if( defined $pen{$tag} and !defined $val ) {
    50 66        
    100 66        
328             # Turn it off
329 2         3 push @sgr, $base + 9;
330 2         7 delete $pen{$tag};
331             }
332             elsif( defined $pen{$tag} and defined $val and $pen{$tag} == $val ) {
333             # Leave it
334             }
335             elsif( defined $val ) {
336 14 100       40 if( $val < 8 ) {
    100          
337             # VGA 8
338 9         21 push @sgr, $base + $val;
339             }
340             elsif( $val < 16 ) {
341             # Hi 16
342 3         8 push @sgr, $base + 60 + ( $val - 8 );
343             }
344             else {
345             # Xterm256 palette 5 = 256 colours
346 2         14 push @sgr, sprintf "%d:%d:%d", $base + 8, 5, $val;
347             }
348 14         89 $pen{$tag} = $val;
349             }
350             }
351              
352             {
353 77 100 100     426 if( defined $pen{sizepos} and !defined $tags{sizepos} ) {
    50 66        
    100 66        
354 1         3 push @sgr, 75; # reset
355 1         3 delete $pen{sizepos};
356             }
357             elsif( defined $pen{sizepos} and defined $tags{sizepos} and $pen{sizepos} eq $tags{sizepos} ) {
358             # Leave it
359             }
360             elsif( defined( my $val = $tags{sizepos} ) ) {
361 4 100       18 if( $val eq "sub" ) {
    50          
362 2         29 push @sgr, 74;
363             }
364             elsif( $val eq "super" ) {
365 2         5 push @sgr, 73;
366             }
367 4         10 $pen{sizepos} = $val;
368             }
369             }
370              
371             {
372 77         180 my $link = $tags{link};
  77         137  
  77         138  
373 77 100       172 my $uri = $link ? $link->{uri} : undef;
374              
375 77 50 33     373 if( defined $osc8_uri and !defined $uri ) {
    50 33        
    100 33        
376 0         0 $ret .= "\e]8;;\e\\";
377 0         0 undef $osc8_uri;
378             }
379             elsif( defined $osc8_uri and defined $uri and $osc8_uri eq $uri ) {
380             # leave it
381             }
382             elsif( defined $uri ) {
383 2         12 $ret .= "\e]8;;" . ( $uri =~ s/[^[:print:]]//gr ) . "\e\\";
384 2         6 $osc8_uri = $uri;
385             }
386             }
387              
388 77 100 100     291 if( @sgr and %pen ) {
    100          
389 35         156 $ret .= "\e[" . join( ";", @sgr ) . "m";
390             }
391             elsif( @sgr ) {
392 19         41 $ret .= "\e[m";
393             }
394              
395 77         317 $ret .= $s;
396             },
397 32 100       363 ( $opts{no_color} ? ( except => [qw( fgindex bgindex )] ) : () ) );
398              
399 32 100       1153 $ret .= "\e[m" if %pen;
400 32 100       103 $ret .= "\e]8;;\e\\" if defined $osc8_uri;
401              
402 32         235 return $ret;
403             }
404              
405             =head2 as_formatting
406              
407             $fmt = $st->as_formatting;
408              
409             Returns a new C instance tagged with
410             L standard tags.
411              
412             =cut
413              
414             sub as_formatting
415             {
416 2     2 1 1468 my $self = shift;
417              
418 2         13 require Convert::Color::XTerm;
419              
420             return String::Tagged->clone( $self,
421             only_tags => [qw(
422             bold under italic strike blink reverse sizepos link
423             altfont
424             fgindex bgindex
425             )],
426             convert_tags => {
427 1 50   1   174 altfont => sub { $_[1] == 1 ? ( monospace => 1 ) : () },
428              
429 1     1   161 fgindex => sub { fg => Convert::Color::XTerm->new( $_[1] ) },
430 0     0   0 bgindex => sub { bg => Convert::Color::XTerm->new( $_[1] ) },
431             },
432 2         53 );
433             }
434              
435             =head2 print_to_terminal
436              
437             $str->print_to_terminal( $fh );
438              
439             I
440              
441             Prints the string to the terminal by building a terminal escape string then
442             printing it to the given IO handle (or C if not supplied).
443              
444             This method will pass the value of the C environment variable to the
445             underlying L method call, meaning if that has a true value
446             then colouring tags will be ignored, yielding a monochrome output. This
447             follows the suggestion of L.
448              
449             =cut
450              
451             sub print_to_terminal
452             {
453 5     5 1 414669 my $self = shift;
454 5         16 my ( $fh, %options ) = @_;
455              
456 5   100     29 $fh //= \*STDOUT;
457              
458 5         18 $options{win32}++ if HAVE_MSWIN32 and not exists $options{win32};
459              
460 5 100       18 if( $options{win32} ) {
461 3         24 $self->String::Tagged::Terminal::Win32Console::print_to_console( $fh, %options );
462             }
463             else {
464 2         14 $fh->print( $self->build_terminal( no_color => $ENV{NO_COLOR} ) );
465             }
466             }
467              
468             =head2 say_to_terminal
469              
470             $str->say_to_terminal( $fh );
471              
472             I
473              
474             Prints the string to the terminal as per L, followed by a
475             linefeed.
476              
477             =cut
478              
479             sub say_to_terminal
480             {
481 1     1 1 8320 my $self = shift;
482 1         4 my ( $fh, %options ) = @_;
483              
484 1   50     5 $fh //= \*STDOUT;
485              
486 1         7 $self->print_to_terminal( $fh, %options );
487 1         24 $fh->say;
488             }
489              
490             =head1 COMPATIBILITY NOTES
491              
492             On Windows, the following notes apply:
493              
494             =over 4
495              
496             =item *
497              
498             On all versions of Windows, the attributes C, C and C
499             are supported. The C attribute is implemented by using high-intensity
500             colours, so will be indistinguishable from using high-intensity colour indexes
501             without bold. The full 256-color palette is not supported by Windows, so it is
502             down-converted to the 16 colours that are.
503              
504             =item *
505              
506             Starting with Windows 10, also C and C are supported.
507              
508             =item *
509              
510             The attributes C, C, C, C are not supported on
511             any Windows version.
512              
513             =item *
514              
515             On Windows, only a single output console is supported.
516              
517             =back
518              
519             =cut
520              
521             =head1 AUTHOR
522              
523             Paul Evans
524              
525             =cut
526              
527             0x55AA;