cleanup handler selection
[fcscs/fcscs.git] / bin / fcscs
1 #!/usr/bin/perl
2
3 # fcscs - fast curses screen content select
4
5 # Copyright (C) 2013-2016  Simon Ruderich
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20
21 use strict;
22 use warnings;
23
24 use v5.10; # say, state
25
26 use Encode ();
27 use Fcntl ();
28 use I18N::Langinfo ();
29
30 use Curses ();
31
32 our $VERSION = '0.01';
33
34
35 =head1 NAME
36
37 fcscs - fast curses screen content select
38
39 =head1 SYNOPSIS
40
41 B<fcscs> [I<options>] I<path/to/screen/capture/file>
42
43 =head1 DESCRIPTION
44
45 B<fcscs> is a small tool which allows quick selection of terminal screen
46 contents (like URLs, paths, regex matches, etc.) and passes the selection to
47 GNU Screen's or Tmux's buffer or any other program. The selection can then
48 quickly be pasted, e.g. in the shell. Requires GNU Screen or Tmux. It's
49 licensed under the GPL 3 or later.
50
51 =head1 OPTIONS
52
53 None so far.
54
55 =head1 USAGE
56
57 GNU Screen setup (add to F<~/.screenrc>):
58
59     bind ^B eval "hardcopy $HOME/.tmp/screen-fcscs" "screen fcscs $HOME/.tmp/screen-fcscs"
60
61 Tmux setup (add to F<~/.tmux.conf>):
62
63     bind-key C-b capture-pane \; save-buffer ~/.tmp/tmux-fcscs \; delete-buffer \; new-window "fcscs ~/.tmp/tmux-fcscs"
64
65 This requires a writable ~/.tmp directory. Adapt the mapping according to your
66 preferences. Ensure these files are not readable by others as they can contain
67 private data (umask or protected directory). B<fcscs> must be in your C<$PATH>
68 for the above mappings to work.
69
70 Pressing the configured mapping (Prefix Ctrl-B in this example) opens B<fcscs>
71 in a new GNU screen/Tmux window. After selection, the content is either passed
72 to external programs (e.g. for URLs) or copied to the paste buffer or directly
73 pasted in your previous window and the new window is closed.
74
75 To select a match just type its number. If the match is unique, the entry is
76 automatically selected (e.g. you press 2 and there are only 19 matches). If
77 there are multiple matches left (e.g. you press 1 and there are more than ten
78 matches available), press return to select the current match (1 in this case)
79 or another number to select the longer match. Use backspace to remove the last
80 entered number.
81
82 Press return before entering a number to select the last (lowest numbered)
83 match. To abort without selecting any match either use "q".
84
85 To change the selection mode (e.g. paths, files, etc.) use one of the mappings
86 explained below. Per default URLs are selected, see options for a way to
87 change this.
88
89 I<NOTE>: When yanking (copying) a temporary file is used to pass the data to
90 GNU screen/Tmux without exposing it to C<ps ux> or C<top>. However this may
91 leak data if those temporary files are written to disk. To prevent this change
92 your C<$TMP> accordingly to point to a memory-only location or encrypted
93 storage.
94
95 If no window appears, try running B<fcscs> manually to catch the error message
96 and please report the bug:
97
98     fcscs /path/to/screen-or-tmux-fcscs-file
99
100 =cut
101
102
103 # CLASSES
104
105 # Helper class for drawing on the screen using Curses.
106 package Screen {
107     sub init {
108         my ($class, $encoding) = @_;
109
110         # Prefer strict UTF-8 handling (see perldoc Encode); just in case.
111         if (lc $encoding eq 'utf8') {
112             $encoding = 'UTF-8';
113         }
114         # Get Encode object to speed up decode()/encode().
115         my $encoding_object = Encode::find_encoding($encoding);
116         die "unsupported encoding '$encoding'" unless ref $encoding_object;
117
118         my $curses = Curses->new or die $!;
119
120         my $self = {
121             encoding        => $encoding,
122             encoding_object => $encoding_object,
123             curses          => $curses,
124             debug           => 0,
125             prompt          => {
126                 flags => undef,
127                 name  => undef,
128                 value => undef,
129             },
130         };
131         bless $self, $class;
132
133         Curses::start_color;
134         # Allow default colors by passing -1 to init_pair. A default color is
135         # not drawn on screen thus allowing terminals with pseudo-transparency
136         # to use the transparent background in place of the default color.
137         Curses::use_default_colors;
138
139         Curses::cbreak;
140         Curses::noecho;
141         $self->cursor(0);
142
143         return $self;
144     }
145     sub deinit {
146         my ($self) = @_;
147
148         Curses::nocbreak;
149         Curses::echo;
150         $self->cursor(1);
151
152         Curses::endwin;
153         return;
154     }
155
156     # Convert between Perl's internal encoding and the terminal's encoding.
157     sub encode {
158         my ($self, $string) = @_;
159         return $self->{encoding_object}->encode($string);
160     }
161     sub decode {
162         my ($self, $string) = @_;
163         return eval { # returns undef on decode failure
164             $self->{encoding_object}->decode($string, Encode::FB_CROAK);
165         };
166     }
167
168     # Create attribute for the given fore-/background colors.
169     sub color_pair {
170         my ($self, $fg, $bg) = @_;
171
172         state $next_color_pair = 1; # must start at 1 for init_pair()
173
174         Curses::init_pair($next_color_pair, $fg, $bg);
175         return Curses::COLOR_PAIR($next_color_pair++);
176     }
177
178     # Draw a string which must fit in the current line. Wrapping/clipping is
179     # not supported and must be handled by the caller.
180     sub draw_simple {
181         my ($self, $y, $x, $attributes, $string) = @_;
182
183         die if $string =~ /\n/;
184         # FIXME: wide characters
185         die if $x + length $string > $self->width;
186
187         $self->{curses}->attron($attributes) if defined $attributes;
188         $self->{curses}->addstr($y, $x, $self->encode($string));
189         $self->{curses}->attroff($attributes) if defined $attributes;
190         return;
191     }
192     # Like draw_simple(), but the string is automatically clipped.
193     sub draw_clipped {
194         my ($self, $y, $x, $attributes, $string) = @_;
195
196         # FIXME: wide characters
197         $string = substr $string, 0, $self->width - $x;
198         $self->draw_simple($y, $x, $attributes, $string);
199         return;
200     }
201     sub draw {
202         my ($self, $y, $x, $attributes, $string) = @_;
203
204         die unless defined $string;
205
206         while (1) {
207             my $offset;
208             # We must print each line separately. Search for next newline or
209             # line end, whichever is closer.
210             if ($string =~ /\n/) {
211                 $offset = $-[0];
212             }
213             # FIXME: wide characters
214             if ($x + length $string > $self->width) {
215                 my $new_offset = $self->width - $x;
216                 if (not defined $offset or $offset > $new_offset) {
217                     $offset = $new_offset;
218                 }
219             }
220             last unless defined $offset;
221
222             # FIXME: wide characters
223             $self->draw_simple($y, $x, $attributes, substr $string, 0, $offset);
224
225             # Don't draw "\n" itself.
226             if ("\n" eq substr $string, $offset, 1) {
227                 $offset++;
228             }
229
230             $string = substr $string, $offset;
231
232             $y++;
233             $x = 0;
234         }
235
236         $self->draw_simple($y, $x, $attributes, $string);
237         return $y;
238     }
239
240     sub draw_prompt {
241         my ($self, $config) = @_;
242
243         my $x = 0;
244         my $y = $self->height - 1;
245
246         # Clear line for better visibility.
247         $self->draw_simple($y, $x, undef, ' ' x $self->width);
248
249         # Draw prompt flags.
250         if (defined (my $s = $self->{prompt}{flags})) {
251             $s = "[$s]";
252             $self->draw_clipped($y, $x, $config->{attribute}{prompt_flags}, $s);
253             $x += length($s) + 1; # space between next element
254         }
255         # Draw prompt name.
256         if (defined (my $s = $self->{prompt}{name})) {
257             $s = "[$s]";
258             $self->draw_clipped($y, $x, $config->{attribute}{prompt_name}, $s);
259             $x += length($s) + 1;
260         }
261         # Draw prompt value, e.g. a search field.
262         if (defined (my $s = $self->{prompt}{value})) {
263             $self->draw_clipped($y, $x, undef, $s);
264             $x += length($s) + 1;
265         }
266         return;
267     }
268
269     sub draw_matches {
270         my ($self, $config, $matches_remove, $matches_add) = @_;
271
272         foreach (@{$matches_remove}) {
273             $self->draw($_->{y}, $_->{x}, Curses::A_NORMAL, $_->{string});
274         }
275
276         my $attr_id     = $config->{attribute}{match_id};
277         my $attr_string = $config->{attribute}{match_string};
278
279         foreach (@{$matches_add}) {
280             $self->draw($_->{y}, $_->{x}, $attr_string, $_->{string});
281             if (defined $_->{id}) {
282                 $self->draw($_->{y}, $_->{x}, $attr_id, $_->{id});
283             }
284         }
285         return;
286     }
287
288     sub die {
289         my ($self, @args) = @_;
290
291         my $attr = $self->color_pair(Curses::COLOR_RED, -1) | Curses::A_BOLD;
292
293         # Clear the screen to improve visibility of the error message.
294         $self->{curses}->clear;
295
296         my $y = $self->draw(0, 0, $attr, "@args");
297
298         if ($self->{debug}) {
299             my $msg;
300             eval {
301                 require Devel::StackTrace;
302             };
303             if ($@) {
304                 $msg = "Devel::StackTrace missing, no stack trace.\n";
305             } else {
306                 my $trace = Devel::StackTrace->new;
307                 $msg = "Stack trace:\n" . $trace->as_string;
308             }
309             $y = $self->draw($y + 1, 0, Curses::A_NORMAL, $msg);
310         }
311
312         $self->draw($y + 1, 0, Curses::A_NORMAL,
313                     'Press any key to terminate fcscs.');
314         $self->refresh;
315
316         $self->getch;
317         $self->deinit;
318         exit 1;
319     }
320
321
322     sub prompt {
323         my ($self, %settings) = @_;
324
325         foreach (keys %settings) {
326             CORE::die if not exists $self->{prompt}{$_};
327             $self->{prompt}{$_} = $settings{$_};
328         }
329         return;
330     }
331
332     # Wrapper for Curses.
333     sub width   { return $Curses::COLS; }
334     sub height  { return $Curses::LINES; }
335     sub refresh { return $_[0]->{curses}->refresh; }
336     sub getch   { return $_[0]->{curses}->getch; }
337     sub cursor  { Curses::curs_set($_[1]); return; }
338 }
339
340
341
342 # FUNCTIONS
343
344 sub debug {
345     my ($config, $module, @args) = @_;
346
347     say STDERR "$module: @args" if $config->{setting}{debug};
348     return;
349 }
350
351
352 sub prepare_input {
353     my ($screen, $input_ref) = @_;
354
355     # Make sure the input fits on the screen by removing the top lines if
356     # necessary.
357     splice @{$input_ref}, 0, -$screen->height;
358
359     # Pad each line with spaces to the screen width to correctly handle
360     # multi-line regexes.
361     # FIXME: wide characters
362     my @padded = map { sprintf '%-*s', $screen->width, $_ } @{$input_ref};
363
364     my $string = join "\n", @padded;
365     return {
366         string => $string,
367         lines  => $input_ref,
368         width  => $screen->width + 1,
369                   # + 1 = "\n", used in input_match_offset_to_coordinates
370     };
371 }
372
373 sub input_match_offset_to_coordinates {
374     my ($width, $offset) = @_;
375
376     die unless defined $offset;
377
378     my $y = int($offset / $width);
379     my $x = $offset - $y * $width;
380     return ($x, $y);
381 }
382
383 sub get_regex_matches {
384     my ($input, $regex) = @_;
385
386     my @matches;
387     while ($input->{string} =~ /$regex/g) {
388         my $offset = $-[1];
389         die "Match group required in regex '$regex'" if not defined $offset;
390
391         my ($x, $y) = input_match_offset_to_coordinates($input->{width},
392                                                         $offset);
393         push @matches, { x => $x, y => $y, string => $1 };
394     }
395     return @matches;
396 }
397
398
399 sub run_command {
400     my ($config, $cmd) = @_;
401
402     debug $config, 'run_command', "running @{$cmd}";
403
404     my $exit = do {
405         # Perl's system() combined with a $SIG{__WARN__} which die()s has
406         # issues due to the fork. The die() in the __WARN__ handler doesn't
407         # die but the program continues after the system().
408         #
409         # If the forked process fails to exec (e.g. program not found) then
410         # the __WARN__ handler is called (because a warning is about to be
411         # displayed) and the die() should display a message and terminate the
412         # process. But due to the fork it doesn't terminate the parent process
413         # and instead changes the return value of system(); it's no longer -1
414         # which makes it impossible to detect that case.
415         #
416         # Perl < 5.18 (found in 5.14) doesn't setup $$ during system() which
417         # makes it impossible to detect if the handler was called from inside
418         # the child.
419         #
420         # Instead, just ignore any warnings during the system(). Thanks to
421         # mauke in #perl on Freenode (2013-10-29 23:30 CET) for the idea to
422         # use no warnings and anno for testing a more recent Perl version with
423         # a working $$.
424         no warnings;
425
426         system { $cmd->[0] } @{$cmd};
427     };
428     if ($exit != 0) {
429         my $msg;
430         if ($? == -1) {
431             $msg = 'failed to execute: ' . $!;
432         } elsif ($? & 127) {
433             $msg = 'killed by signal ' . ($? & 127);
434         } else {
435             $msg = 'exited with code ' . ($? >> 8);
436         }
437         die "system(@{$cmd}) $msg.";
438     }
439     return;
440 }
441 sub run_in_background {
442     my ($config, $sub) = @_;
443
444     debug $config, 'run_in_background', "running $sub";
445
446     my $pid = fork;
447     defined $pid or die $!;
448
449     if ($pid == 0) {
450         # The terminal multiplexer sends a SIGHUP to the process when it
451         # closes the window (because the parent process has exited).
452         local $SIG{HUP} = 'IGNORE';
453
454         # Necessary for GNU screen or it'll keep the window open until the
455         # paste command has run.
456         close STDIN  or die $!;
457         close STDOUT or die $!;
458         close STDERR or die $!;
459
460         # Double-fork to prevent zombies.
461         my $pid = fork;
462         defined $pid or die $!;
463         if ($pid == 0) { # child
464             # Disable debug mode as writing will fail with closed STDERR.
465             $config->{setting}{debug} = 0;
466
467             $sub->();
468         }
469         exit;
470     }
471     waitpid $pid, 0 or die $!;
472     return;
473 }
474
475
476 sub select_match {
477     my ($name, $screen, $config, $input, $matches) = @_;
478
479     debug $config, 'select_match', 'started';
480
481     return if @{$matches} == 0;
482     # Don't return on initial run to give the user a chance to select another
483     # mode, e.g. to switch from URL selection to search selection.
484     if (@{$matches} == 1 and not $config->{state}->{initial}) {
485         return { match => $matches->[0] };
486     }
487     $config->{state}{initial} = 0;
488
489     my @sorted = sort { $b->{y} <=> $a->{y} or $b->{x} <=> $a->{x} } @{$matches};
490
491     my $i = 1;
492     foreach (@sorted) {
493         $_->{id} = $i++;
494     }
495
496     $screen->prompt(name => $name, value => undef);
497     $screen->draw_prompt($config);
498
499     $screen->draw_matches($config, [], $matches);
500     $screen->refresh;
501
502     my $number = 0;
503     while (1) {
504         my $char = $screen->getch;
505         if ($char =~ /^\d$/) {
506             $number = $number * 10 + $char;
507         } elsif ($char eq "\b" or $char eq "\x7f") { # backspace
508             $number = int($number / 10);
509         } elsif ($char eq "\n") {
510             if ($number == 0) { # number without selection matches last entry
511                 $number = 1;
512             }
513             last;
514
515         # Selecting a new mode requires falling through into the main input
516         # loop and then starting the new mode.
517         } elsif (defined $config->{mapping}{mode}{$char}) {
518             $screen->draw_matches($config, $matches, []); # clear matches
519             return { key => $char };
520         # All other mappings stay in the current mode.
521         } elsif (defined (my $m = $config->{mapping}{simple}{$char})) {
522             $m->($char, $screen, $config, $input);
523             next;
524
525         } else {
526             next; # ignore unknown mappings
527         }
528
529         last if $number > 0 and $number * 10 > @{$matches}; # unique match
530
531         my @remaining = $number == 0
532                       ? @{$matches}
533                       : grep { $_->{id} =~ /^$number/ } @{$matches};
534         $screen->draw_matches($config, $matches, \@remaining);
535         $screen->refresh;
536     }
537
538     foreach (@{$matches}) {
539         return { match => $_ } if $_->{id} == $number;
540     }
541     debug $config, 'select_match', 'no match selected';
542     return { match => undef };
543 }
544
545
546 sub mapping_paste {
547     my ($key, $screen, $config, $input) = @_;
548
549     debug $config, 'mapping_paste', 'started';
550
551     $config->{state}{handler} = $config->{handler}{paste};
552
553     $screen->prompt(flags => 'P'); # paste
554     $screen->draw_prompt($config);
555     $screen->refresh;
556
557     return {};
558 }
559 sub mapping_yank {
560     my ($key, $screen, $config, $input) = @_;
561
562     debug $config, 'mapping_yank', 'started';
563
564     $config->{state}{handler} = $config->{handler}{yank};
565
566     $screen->prompt(flags => 'Y'); # yank
567     $screen->draw_prompt($config);
568     $screen->refresh;
569
570     return {};
571 }
572
573
574 sub mapping_mode_path {
575     my ($key, $screen, $config, $input) = @_;
576
577     debug $config, 'mapping_mode_path', 'started';
578
579     my @matches = get_regex_matches($input, $config->{regex}{path});
580     return {
581         select  => 'path select',
582         matches => \@matches,
583         handler => $config->{handler}{yank},
584     };
585 }
586 sub mapping_mode_url {
587     my ($key, $screen, $config, $input) = @_;
588
589     debug $config, 'mapping_mode_url', 'started';
590
591     my @matches = get_regex_matches($input, $config->{regex}{url});
592     return {
593         select  => 'url select',
594         matches => \@matches,
595         handler => $config->{handler}{url},
596     };
597 }
598
599 sub mapping_mode_search {
600     my ($key, $screen, $config, $input) = @_;
601
602     debug $config, 'mapping_mode_search', 'started';
603
604     $screen->cursor(1);
605
606     my $search = ''; # encoded
607     my @last_matches;
608     while (1) {
609         # getch doesn't return decoded characters but raw input bytes. Wait
610         # until the input character is complete.
611         my $value = $screen->decode($search);
612         $value = '' unless defined $value; # undef on decode failure
613
614         $screen->prompt(name => 'search', value => $value);
615         $screen->draw_prompt($config);
616         $screen->refresh;
617
618         my $char = $screen->getch;
619         # TODO: readline editing support
620         if ($char eq "\n") {
621             last;
622         } elsif ($char eq "\b" or $char eq "\x7f") { # backspace
623             # Remove a character, not a byte.
624             $search = $screen->decode($search);
625             chop $search;
626             $search = $screen->encode($search);
627         } else {
628             $search .= $char;
629             next unless defined $screen->decode($search);
630         }
631
632         my @matches;
633         if ($search ne '') {
634             my $case = '';
635             if (($config->{setting}{smartcase} and $search eq lc $search)
636                     or $config->{setting}{ignorecase}) {
637                 $case = '(?i)';
638             }
639             # Ignore invalid regexps.
640             # TODO: display warning on error?
641             eval {
642                 @matches = get_regex_matches($input, qr/($case$search)/);
643             };
644         }
645         $screen->draw_matches($config, \@last_matches, \@matches);
646         @last_matches = @matches;
647     }
648
649     $screen->cursor(0);
650
651     return {
652         select  => 'search',
653         matches => \@last_matches,
654         handler => $config->{handler}{yank},
655     };
656 }
657
658 sub mapping_quit {
659     my ($key, $screen, $config, $input) = @_;
660
661     # Key is necessary to fall through to main event loop which then quits.
662     return { key => $key, quit => 1 };
663 }
664
665
666 sub handler_yank {
667     my ($screen, $config, $match) = @_;
668
669     debug $config, 'handler_yank', 'started';
670
671     require File::Temp;
672
673     # Use a temporary file to prevent leaking the yanked data to other users
674     # with the command line, e.g. ps aux or top.
675     my ($fh, $tmp) = File::Temp::tempfile(); # dies on its own
676     print $fh $screen->encode($match->{value});
677     close $fh or die $!;
678
679     if ($config->{setting}{multiplexer} eq 'screen') {
680         debug $config, 'handler_yank', 'using screen';
681
682         # GNU screen displays an annoying "Slurping X characters into buffer".
683         # Use 'msgwait 0' as a hack to disable it.
684         my $msgwait = $config->{setting}{screen_msgwait};
685         run_command($config, ['screen', '-X', 'msgwait', 0]);
686         run_command($config, ['screen', '-X', 'readbuf', $tmp]);
687         run_command($config, ['screen', '-X', 'msgwait', $msgwait]);
688     } elsif ($config->{setting}{multiplexer} eq 'tmux') {
689         debug $config, 'handler_yank', 'using tmux';
690
691         run_command($config, ['tmux', 'load-buffer', $tmp]);
692     } else {
693         die 'unsupported multiplexer';
694     }
695
696     unlink $tmp or die $!;
697     return;
698 }
699 sub handler_paste {
700     my ($screen, $config, $match) = @_;
701
702     debug $config, 'handler_paste', 'started';
703
704     require Time::HiRes;
705
706     my @cmd;
707     if ($config->{setting}{multiplexer} eq 'screen') {
708         debug $config, 'handler_paste', 'using screen';
709         @cmd = qw( screen -X paste . );
710     } elsif ($config->{setting}{multiplexer} eq 'tmux') {
711         debug $config, 'handler_paste', 'using tmux';
712         @cmd = qw( tmux paste-buffer );
713     } else {
714         die 'unsupported multiplexer';
715     }
716
717     run_in_background($config, sub {
718         # We need to get the data in the paste buffer before we can paste
719         # it.
720         handler_yank($screen, $config, $match);
721
722         # Sleep until we switch back to the current window.
723         Time::HiRes::usleep($config->{setting}{paste_sleep});
724
725         run_command($config, \@cmd);
726     });
727     return;
728 }
729 sub handler_url {
730     my ($screen, $config, $match) = @_;
731
732     debug $config, 'handler_url', "opening $match->{value}";
733
734     run_in_background($config, sub {
735         my @cmd = map { $screen->encode($_) } (
736             @{$config->{setting}{browser}},
737             $match->{value},
738         );
739         run_command($config, \@cmd);
740     });
741     return;
742 }
743
744
745
746 # CONFIGURATION DEFAULTS
747
748 =head1 CONFIGURATION
749
750 fcscs is configured through F<~/.fcscsrc> or F<~/.config/fcscs/fcscsrc> which
751 is a normal Perl script with all of Perl's usual features.
752
753 All configuration values are stored in the hash C<%config>. All manually
754 defined keys overwrite the default settings.
755
756 A simple F<~/.fcscsrc> could look like this (for details about the used
757 settings see below):
758
759     use strict;
760     use warnings;
761
762     use Curses; # for COLOR_* and A_* constants
763
764     our %config;
765
766     # Draw matches in blue.
767     $config{attribute}{match_string} = color_pair(COLOR_BLUE, -1);
768     # Enable Vim-like 'smartcase', ignore case until an upper character is
769     # searched.
770     $config{setting}{smartcase} = 1;
771
772     # Use chromium to open URLs if running under X, elinks otherwise.
773     if (defined $ENV{DISPLAY}) {
774         $config{setting}{browser} = ['chromium'];
775     } else {
776         $config{setting}{browser} = ['elinks'];
777     }
778
779     # Let fcscs know the file was loaded successfully.
780     1;
781
782 =cut
783
784
785 if (@ARGV != 1) {
786     require Pod::Usage;
787     Pod::Usage::pod2usage(2);
788 }
789
790
791 # Determine terminal encoding from the environment ($ENV{LANG} or similar).
792 my $encoding = I18N::Langinfo::langinfo(I18N::Langinfo::CODESET);
793
794 my $screen = Screen->init($encoding);
795
796 # We must restore the screen before exiting.
797 local $SIG{INT} = sub {
798     $screen->deinit;
799     exit 128 + 2;
800 };
801 # Make all warnings fatal to make sure they don't get lost (stderr is normally
802 # not displayed).
803 local $SIG{__WARN__} = sub {
804     $screen->die('warning', @_);
805 };
806
807
808
809 =head2 MAPPINGS
810
811 I<NOTE>: Mappings are split in two categories: Mode mappings which change the
812 selection and may receive additional input (e.g. a search string) and simple
813 mappings which only change some value. Mode mappings are configured via
814 C<$config{mapping}{mode}>, simple mappings via C<$config{mapping}{simple}>.
815
816 The following mode mappings are available by default (the function to remap
817 them in parentheses):
818
819 =over
820
821 =item B<f> select absolute/relative paths (C<\&mapping_mode_path>)
822
823 =item B<u> select URLs (C<\&mapping_mode_url>)
824
825 =item B</> search for regex to get selection (C<\&mapping_mode_search>)
826
827 =item B<q> quit fcscs (C<\&mapping_quit>)
828
829 =back
830
831 The following simple mappings are available by default:
832
833 =over
834
835 =item B<p> enable pasting (C<\&mapping_paste>)
836
837 =item B<y> enable yanking (copying) (C<\&mapping_yank>)
838
839 =back
840
841 All (single-byte) keys except numbers, backspace and return can be mapped.
842
843 Unknown mappings are ignored when pressing keys.
844
845 To remove a default mapping, delete it from the mapping hash.
846
847 Example:
848
849     # Map 'p' to select paths, 'P' to enable pasting.
850     $config{mapping}{mode}{p} = \&mapping_mode_path;
851     $config{mapping}{simple}{P} = \&mapping_paste;
852
853     # Disable 'f' mapping.
854     delete $config{mapping}{mode}{f};
855
856 =cut
857 my %mapping_mode = (
858     f   => \&mapping_mode_path,
859     u   => \&mapping_mode_url,
860     '/' => \&mapping_mode_search,
861     q   => \&mapping_quit,
862 );
863 my %mapping_simple = (
864     p => \&mapping_paste,
865     y => \&mapping_yank,
866 );
867
868 =head2 ATTRIBUTES
869
870 Attributes are used to style the output. They must be Curses attributes.
871 Defaults in parentheses (foreground, background, attribute).
872
873 =over
874
875 =item B<match_id>      attribute for match numbers (red, default, bold)
876
877 =item B<match_string>  attribute for matches (yellow, default, normal)
878
879 =item B<prompt_name>   attribute for prompt name (standout)
880
881 =item B<prompt_flags>  attribute for prompt flags (standout)
882
883 =back
884
885 Example:
886
887     # Draw prompt flags in bold red with default background color.
888     $config{attribute}{prompt_flags}
889         = Curses::A_BOLD
890         | color_pair(Curses::COLOR_RED, -1);
891
892 =cut
893 my %attribute = (
894     match_id     => $screen->color_pair(Curses::COLOR_RED, -1)
895                     | Curses::A_BOLD,
896     match_string => $screen->color_pair(Curses::COLOR_YELLOW, -1),
897     prompt_name  => Curses::A_STANDOUT,
898     prompt_flags => Curses::A_STANDOUT,
899 );
900
901 =head2 SETTINGS
902
903 Defaults in parentheses.
904
905 =over
906
907 =item B<debug>          enable debug mode (redirect stderr when enabling) (C<0>)
908
909 =item B<initial_mode>   start in this mode, must be a valid mode mapping (C<\&mapping_mode_url>)
910
911 =item B<multiplexer>    set multiplexer ("screen" or "tmux") if not autodetected (C<undef>)
912
913 =item B<ignorecase>     ignore case when searching (C<0>)
914
915 =item B<smartcase>      ignore case unless one uppercase character is searched (C<1>)
916
917 =item B<paste_sleep>    sleep x us before running paste command (C<100_000>)
918
919 =item B<screen_msgwait> GNU Screen's msgwait variable, used when yanking (C<5>)
920
921 =item B<browser>        browser command as array reference (C<['x-www-browser']>)
922
923 =back
924
925 Example:
926
927     # Select paths on startup instead of URLs.
928     $config{setting}{initial_mode} = \&mapping_mode_path;
929
930 =cut
931 my %setting = (
932     # options
933     debug          => 0,
934     initial_mode   => \&mapping_mode_url,
935     multiplexer    => undef,
936     ignorecase     => 0,
937     smartcase      => 1,
938     paste_sleep    => 100_000,
939     screen_msgwait => 5,
940     # commands
941     browser        => ['x-www-browser'],
942 );
943
944 =head2 REGEXPS
945
946 =over
947
948 =item B<url> used by C<\&mapping_mode_url()>
949
950 =item B<path> used by C<\&mapping_mode_path()>
951
952 =back
953
954 Example:
955
956     # Select all non-whitespace characters when searching for paths.
957     $config{regex}{path} = qr{(\S+)};
958
959 =cut
960 my %regex = (
961     # Taken from urlview's default configuration file, thanks.
962     url  => qr{((?:(?:(?:http|https|ftp|gopher)|mailto):(?://)?[^ <>"\t]*|(?:www|ftp)[0-9]?\.[-a-z0-9.]+)[^ .,;\t\n\r<">\):]?[^, <>"\t]*[^ .,;\t\n\r<">\):])},
963     path => qr{(~?[a-zA-Z0-9_./-]*/[a-zA-Z0-9_./-]+)},
964 );
965
966 =head2 HANDLERS
967
968 Handlers are used to perform actions on the selected string.
969
970 The following handlers are available, defaults in parentheses.
971
972 =over
973
974 =item B<yank>  used to yank (copy) selection to paste buffer (C<\&handler_yank>)
975
976 =item B<paste> used to paste selection into window (C<\&handler_paste>)
977
978 =item B<url>   used to open URLs (e.g. in a browser) (C<\&handler_url>)
979
980 =back
981
982 Example:
983
984     # Download YouTube videos with a custom wrapper, handle all other URLs
985     # with the default URL handler.
986     $config{handler}{url} = sub {
987         my ($screen, $config, $match) = @_;
988
989         if ($match->{value} =~ m{^https://www.youtube.com/}) {
990             return run_in_background($config, sub {
991                 run_command($config, ['youtube-dl-wrapper', $match->{value}]);
992             });
993         }
994         handler_url(@_);
995     };
996
997 =cut
998 my %handler = (
999     yank  => \&handler_yank,
1000     paste => \&handler_paste,
1001     url   => \&handler_url,
1002 );
1003
1004 my %state = (
1005     initial => 1, # used by select_match() for 'initial_mode'
1006     handler => undef,
1007 );
1008
1009
1010
1011 # CONFIGURATION "API"
1012
1013 =head2 FUNCTIONS
1014
1015 The following functions are available:
1016
1017     color_pair($fg, $bg)
1018
1019 Create a new Curses attribute with the given fore- and background color.
1020
1021     mapping_mode_path()
1022     mapping_mode_url()
1023     mapping_mode_search()
1024
1025     mapping_paste()
1026     mapping_yank()
1027     mapping_quit()
1028
1029 Used as mappings, see L</MAPPINGS> above.
1030
1031     handler_yank()
1032     handler_paste()
1033     handler_url()
1034
1035 Used as handler to yank, paste selection or open URL in browser.
1036
1037     get_regex_matches()
1038     select_match()
1039     run_command()
1040     run_in_background()
1041
1042 Helper functions when writing custom mappings, see the source for details.
1043
1044 Example:
1045
1046     TODO
1047
1048 =cut
1049
1050 # All variables and functions which are usable by ~/.fcscsrc.
1051 package Fcscs {
1052     our $screen; # "private"
1053     our %config;
1054
1055     sub color_pair { return $screen->color_pair(@_); }
1056
1057     sub mapping_mode_path { return main::mapping_mode_path(@_); }
1058     sub mapping_mode_url { return main::mapping_mode_url(@_); }
1059     sub mapping_mode_search { return main::mapping_mode_search(@_); }
1060
1061     sub mapping_paste { return main::mapping_paste(@_); }
1062     sub mapping_yank { return main::mapping_yank(@_); }
1063     sub mapping_quit { return main::mapping_quit(@_); }
1064
1065     sub handler_yank { return main::handler_yank(@_); }
1066     sub handler_paste { return main::handler_paste(@_); }
1067     sub handler_url { return main::handler_url(@_); }
1068
1069     sub get_regex_matches { return main::get_regex_matches(@_); }
1070     sub select_match { return main::select_match(@_); }
1071
1072     sub run_command { return main::run_command(@_); }
1073     sub run_in_background { return main::run_in_background(@_); }
1074 }
1075 $Fcscs::screen = $screen;
1076
1077
1078
1079 # LOAD USER CONFIG
1080
1081 # Alias %config and %Fcscs::config. %config is less to type.
1082 our %config;
1083 local *config = \%Fcscs::config;
1084
1085 $config{mapping}{mode}   = \%mapping_mode;
1086 $config{mapping}{simple} = \%mapping_simple;
1087 $config{attribute}       = \%attribute;
1088 $config{setting}         = \%setting;
1089 $config{regex}           = \%regex;
1090 $config{handler}         = \%handler;
1091 $config{state}           = \%state;
1092
1093 package Fcscs {
1094     my @configs = ("$ENV{HOME}/.fcscsrc",
1095                    "$ENV{HOME}/.config/fcscs/fcscsrc");
1096     foreach my $path (@configs) {
1097         my $decoded = $screen->decode($path);
1098
1099         # Load configuration file. Checks have a race condition if the home
1100         # directory is writable by an attacker (but then the user is screwed
1101         # anyway).
1102         next unless -e $path;
1103         if (not -O $path) {
1104             $screen->die("Config '$decoded' not owned by current user!");
1105         }
1106         # Make sure the file is not writable by other users. Doesn't handle
1107         # ACLs and see comment above about race conditions.
1108         my @stat = stat $path or die $!;
1109         my $mode = $stat[2];
1110         if (($mode & Fcntl::S_IWGRP) or ($mode & Fcntl::S_IWOTH)) {
1111             die "Config '$decoded' must not be writable by other users.";
1112         }
1113
1114         my $result = do $path;
1115         if (not $result) {
1116             $screen->die("Failed to parse '$decoded': $@") if $@;
1117             $screen->die("Failed to do '$decoded': $!") unless defined $result;
1118             $screen->die("Failed to run '$decoded'.");
1119         }
1120
1121         last; # success, don't load more files
1122     }
1123 }
1124 $screen->{debug} = $config{setting}{debug};
1125
1126
1127 # MAIN
1128
1129 eval {
1130     # Auto-detect current multiplexer.
1131     if (not defined $config{setting}{multiplexer}) {
1132         if (defined $ENV{STY} and defined $ENV{TMUX}) {
1133             die 'Found both $STY and $TMUX, set $config{setting}{multiplexer}.';
1134         } elsif (defined $ENV{STY}) {
1135             $config{setting}{multiplexer} = 'screen';
1136         } elsif (defined $ENV{TMUX}) {
1137             $config{setting}{multiplexer} = 'tmux';
1138         } else {
1139             die 'No multiplexer found.';
1140         }
1141     }
1142
1143     my $binmode = $encoding;
1144     # GNU screen stores the screen dump for unknown reasons as ISO-8859-1
1145     # instead of the currently active encoding.
1146     if ($config{setting}{multiplexer} eq 'screen') {
1147         $binmode = 'ISO-8859-1';
1148     }
1149
1150     my @input_lines;
1151     open my $fh, '<', $ARGV[0] or die $!;
1152     binmode $fh, ":encoding($binmode)" or die $!;
1153     while (<$fh>) {
1154         chomp;
1155         push @input_lines, $_;
1156     }
1157     close $fh or die $!;
1158
1159     my $input = prepare_input($screen, \@input_lines);
1160
1161     # Display original screen content.
1162     my $y = 0;
1163     foreach (@{$input->{lines}}) {
1164         $screen->draw_simple($y++, 0, undef, $_);
1165     }
1166     $screen->refresh;
1167
1168
1169     my $mapping = $config{setting}{initial_mode};
1170
1171     my $key;
1172     while (1) {
1173         if (not defined $mapping) {
1174             $key = $screen->getch unless defined $key;
1175             debug \%config, 'input', "got key '$key'";
1176
1177             $mapping = $config{mapping}{mode}{$key};
1178             $mapping = $config{mapping}{simple}{$key} unless defined $mapping;
1179             if (not defined $mapping) { # ignore unknown mappings
1180                 $key = undef;
1181                 next;
1182             }
1183         }
1184
1185         debug \%config, 'input', 'running mapping';
1186         my $result = $mapping->($key, $screen, \%config, $input);
1187         $mapping = undef;
1188
1189 RESULT:
1190         if (defined $result->{quit}) {
1191             debug \%config, 'input', 'quitting';
1192             last;
1193         }
1194         if (defined $result->{key}) {
1195             $key = $result->{key}; # lookup another mapping
1196             debug \%config, 'input', "processing new key: '$key'";
1197             next;
1198         }
1199         if (defined $result->{select}) {
1200             debug \%config, 'input', 'selecting match';
1201             my $tmp = $result;
1202             $result = select_match($result->{select},
1203                                 $screen, \%config, $input,
1204                                 $result->{matches});
1205             $result->{handler} = $tmp->{handler};
1206             goto RESULT; # reprocess special entries in result
1207         }
1208         if (defined $result->{match}) {
1209             if (not defined $result->{match}->{value}) {
1210                 $result->{match}->{value} = $result->{match}->{string};
1211             }
1212
1213             debug \%config, 'input', 'running handler';
1214
1215             # Choose handler with falling priority.
1216             my @handlers = (
1217                 $config{state}{handler},     # set by user
1218                 $result->{match}->{handler}, # set by match
1219                 $result->{handler},          # set by mapping
1220                 $config{handler}{yank},      # fallback
1221             );
1222             foreach my $handler (@handlers) {
1223                 next unless defined $handler;
1224
1225                 $handler->($screen, \%config, $result->{match});
1226                 last;
1227             }
1228             last;
1229         }
1230
1231         $key = undef; # get next key from user
1232     }
1233 };
1234 if ($@) {
1235     $screen->die("$@");
1236 }
1237
1238 $screen->deinit;
1239
1240 __END__
1241
1242 =head1 EXIT STATUS
1243
1244 =over 4
1245
1246 =item B<0>
1247
1248 Success.
1249
1250 =item B<1>
1251
1252 An error occurred.
1253
1254 =item B<2>
1255
1256 Invalid arguments/options.
1257
1258 =back
1259
1260 =head1 AUTHOR
1261
1262 Simon Ruderich E<lt>simon@ruderich.orgE<gt>
1263
1264 =head1 LICENSE AND COPYRIGHT
1265
1266 Copyright (C) 2013-2016 by Simon Ruderich
1267
1268 This program is free software: you can redistribute it and/or modify
1269 it under the terms of the GNU General Public License as published by
1270 the Free Software Foundation, either version 3 of the License, or
1271 (at your option) any later version.
1272
1273 This program is distributed in the hope that it will be useful,
1274 but WITHOUT ANY WARRANTY; without even the implied warranty of
1275 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1276 GNU General Public License for more details.
1277
1278 You should have received a copy of the GNU General Public License
1279 along with this program.  If not, see E<lt>http://www.gnu.org/licenses/E<gt>.
1280
1281 =head1 SEE ALSO
1282
1283 L<screen(1)>, L<tmux(1)>, L<urlview(1)>
1284
1285 =cut