]> ruderich.org/simon Gitweb - fcscs/fcscs.git/blob - bin/fcscs
initial commit
[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 ($x, $y) = input_match_offset_to_coordinates($input->{width},
389                                                         $-[0]);
390         push @matches, { x => $x, y => $y, string => $1 };
391     }
392     return @matches;
393 }
394
395
396 sub run_command {
397     my ($config, $cmd) = @_;
398
399     debug $config, 'run_command', "running @{$cmd}";
400
401     my $exit = do {
402         # Perl's system() combined with a $SIG{__WARN__} which die()s has
403         # issues due to the fork. The die() in the __WARN__ handler doesn't
404         # die but the program continues after the system().
405         #
406         # If the forked process fails to exec (e.g. program not found) then
407         # the __WARN__ handler is called (because a warning is about to be
408         # displayed) and the die() should display a message and terminate the
409         # process. But due to the fork it doesn't terminate the parent process
410         # and instead changes the return value of system(); it's no longer -1
411         # which makes it impossible to detect that case.
412         #
413         # Perl < 5.18 (found in 5.14) doesn't setup $$ during system() which
414         # makes it impossible to detect if the handler was called from inside
415         # the child.
416         #
417         # Instead, just ignore any warnings during the system(). Thanks to
418         # mauke in #perl on Freenode (2013-10-29 23:30 CET) for the idea to
419         # use no warnings and anno for testing a more recent Perl version with
420         # a working $$.
421         no warnings;
422
423         system { $cmd->[0] } @{$cmd};
424     };
425     if ($exit != 0) {
426         my $msg;
427         if ($? == -1) {
428             $msg = 'failed to execute: ' . $!;
429         } elsif ($? & 127) {
430             $msg = 'killed by signal ' . ($? & 127);
431         } else {
432             $msg = 'exited with code ' . ($? >> 8);
433         }
434         die "system(@{$cmd}) $msg.";
435     }
436     return;
437 }
438 sub run_in_background {
439     my ($config, $sub) = @_;
440
441     debug $config, 'run_in_background', "running $sub";
442
443     my $pid = fork;
444     defined $pid or die $!;
445
446     if ($pid == 0) {
447         # The terminal multiplexer sends a SIGHUP to the process when it
448         # closes the window (because the parent process has exited).
449         local $SIG{HUP} = 'IGNORE';
450
451         # Necessary for GNU screen or it'll keep the window open until the
452         # paste command has run.
453         close STDIN  or die $!;
454         close STDOUT or die $!;
455         close STDERR or die $!;
456
457         # Double-fork to prevent zombies.
458         my $pid = fork;
459         defined $pid or die $!;
460         if ($pid == 0) { # child
461             $sub->();
462         }
463         exit;
464     }
465     waitpid $pid, 0 or die $!;
466     return;
467 }
468
469
470 sub select_match {
471     my ($name, $screen, $config, $input, $matches) = @_;
472
473     debug $config, 'select_match', 'started';
474
475     return if @{$matches} == 0;
476     # Don't return on initial run to give the user a chance to select another
477     # mode, e.g. to switch from URL selection to search selection.
478     if (@{$matches} == 1 and not $config->{state}->{initial}) {
479         return { match => $matches->[0] };
480     }
481     $config->{state}{initial} = 0;
482
483     my @sorted = sort { $b->{y} <=> $a->{y} or $b->{x} <=> $a->{x} } @{$matches};
484
485     my $i = 1;
486     foreach (@sorted) {
487         $_->{id} = $i++;
488     }
489
490     $screen->prompt(name => $name, value => undef);
491     $screen->draw_prompt($config);
492
493     $screen->draw_matches($config, [], $matches);
494     $screen->refresh;
495
496     my $number = 0;
497     while (1) {
498         my $char = $screen->getch;
499         if ($char =~ /^\d$/) {
500             $number = $number * 10 + $char;
501         } elsif ($char eq "\b" or $char eq "\x7f") { # backspace
502             $number = int($number / 10);
503         } elsif ($char eq "\n") {
504             if ($number == 0) { # number without selection matches last entry
505                 $number = 1;
506             }
507             last;
508
509         # Selecting a new mode requires falling through into the main input
510         # loop and then starting the new mode.
511         } elsif (defined $config->{mapping}{mode}{$char}) {
512             $screen->draw_matches($config, $matches, []); # clear matches
513             return { key => $char };
514         # All other mappings stay in the current mode.
515         } elsif (defined (my $m = $config->{mapping}{simple}{$char})) {
516             $m->($char, $screen, $config, $input);
517             next;
518
519         } else {
520             next; # ignore unknown mappings
521         }
522
523         last if $number > 0 and $number * 10 > @{$matches}; # unique match
524
525         my @remaining = $number == 0
526                       ? @{$matches}
527                       : grep { $_->{id} =~ /^$number/ } @{$matches};
528         $screen->draw_matches($config, $matches, \@remaining);
529         $screen->refresh;
530     }
531
532     foreach (@{$matches}) {
533         return { match => $_ } if $_->{id} == $number;
534     }
535     debug $config, 'select_match', 'no match selected';
536     return { match => undef };
537 }
538
539
540 sub mapping_paste {
541     my ($key, $screen, $config, $input) = @_;
542
543     debug $config, 'mapping_paste', 'started';
544
545     $config->{state}{handler} = \&handler_paste;
546
547     $screen->prompt(flags => 'P'); # paste
548     $screen->draw_prompt($config);
549     $screen->refresh;
550
551     return {};
552 }
553 sub mapping_yank {
554     my ($key, $screen, $config, $input) = @_;
555
556     debug $config, 'mapping_yank', 'started';
557
558     $config->{state}{handler} = \&handler_yank;
559
560     $screen->prompt(flags => 'Y'); # yank
561     $screen->draw_prompt($config);
562     $screen->refresh;
563
564     return {};
565 }
566
567
568 sub mapping_mode_path {
569     my ($key, $screen, $config, $input) = @_;
570
571     debug $config, 'mapping_mode_path', 'started';
572
573     my @matches = get_regex_matches($input, $config->{regex}{path});
574     return {
575         select  => 'path select',
576         matches => \@matches,
577         handler => \&handler_yank,
578     };
579 }
580 sub mapping_mode_url {
581     my ($key, $screen, $config, $input) = @_;
582
583     debug $config, 'mapping_mode_url', 'started';
584
585     my @matches = get_regex_matches($input, $config->{regex}{url});
586     return {
587         select  => 'url select',
588         matches => \@matches,
589         handler => \&handler_url,
590     };
591 }
592
593 sub mapping_mode_search {
594     my ($key, $screen, $config, $input) = @_;
595
596     debug $config, 'mapping_mode_search', 'started';
597
598     $screen->cursor(1);
599
600     my $search = ''; # encoded
601     my @last_matches;
602     while (1) {
603         # getch doesn't return decoded characters but raw input bytes. Wait
604         # until the input character is complete.
605         my $value = $screen->decode($search);
606         $value = '' unless defined $value; # undef on decode failure
607
608         $screen->prompt(name => 'search', value => $value);
609         $screen->draw_prompt($config);
610         $screen->refresh;
611
612         my $char = $screen->getch;
613         # TODO: readline editing support
614         if ($char eq "\n") {
615             last;
616         } elsif ($char eq "\b" or $char eq "\x7f") { # backspace
617             # Remove a character, not a byte.
618             $search = $screen->decode($search);
619             chop $search;
620             $search = $screen->encode($search);
621         } else {
622             $search .= $char;
623             next unless defined $screen->decode($search);
624         }
625
626         my @matches;
627         if ($search ne '') {
628             my $case = '';
629             if (($config->{setting}{smartcase} and $search eq lc $search)
630                     or $config->{setting}{ignorecase}) {
631                 $case = '(?i)';
632             }
633             # Ignore invalid regexps.
634             # TODO: display warning on error?
635             eval {
636                 @matches = get_regex_matches($input, qr/($case$search)/);
637             };
638         }
639         $screen->draw_matches($config, \@last_matches, \@matches);
640         @last_matches = @matches;
641     }
642
643     $screen->cursor(0);
644
645     return {
646         select  => 'search',
647         matches => \@last_matches,
648         handler => \&handler_yank,
649     };
650 }
651
652 sub mapping_quit {
653     my ($key, $screen, $config, $input) = @_;
654
655     # Key is necessary to fall through to main event loop which then quits.
656     return { key => $key, quit => 1 };
657 }
658
659
660 sub handler_yank {
661     my ($screen, $config, $match) = @_;
662
663     debug $config, 'handler_yank', 'started';
664
665     require File::Temp;
666
667     # Use a temporary file to prevent leaking the yanked data to other users
668     # with the command line, e.g. ps aux or top.
669     my ($fh, $tmp) = File::Temp::tempfile(); # dies on its own
670     print $fh $screen->encode($match->{string});
671     close $fh or die $!;
672
673     if ($config->{setting}{multiplexer} eq 'screen') {
674         debug $config, 'handler_yank', 'using screen';
675
676         # GNU screen displays an annoying "Slurping X characters into buffer".
677         # Use 'msgwait 0' as a hack to disable it.
678         my $msgwait = $config->{setting}{screen_msgwait};
679         run_command($config, ['screen', '-X', 'msgwait', 0]);
680         run_command($config, ['screen', '-X', 'readbuf', $tmp]);
681         run_command($config, ['screen', '-X', 'msgwait', $msgwait]);
682     } elsif ($config->{setting}{multiplexer} eq 'tmux') {
683         debug $config, 'handler_yank', 'using tmux';
684
685         run_command($config, ['tmux', 'load-buffer', $tmp]);
686     } else {
687         die 'unsupported multiplexer';
688     }
689
690     unlink $tmp or die $!;
691     return;
692 }
693 sub handler_paste {
694     my ($screen, $config, $match) = @_;
695
696     debug $config, 'handler_paste', 'started';
697
698     require Time::HiRes;
699
700     my @cmd;
701     if ($config->{setting}{multiplexer} eq 'screen') {
702         debug $config, 'handler_paste', 'using screen';
703         @cmd = qw( screen -X paste . );
704     } elsif ($config->{setting}{multiplexer} eq 'tmux') {
705         debug $config, 'handler_paste', 'using tmux';
706         @cmd = qw( tmux paste-buffer );
707     } else {
708         die 'unsupported multiplexer';
709     }
710
711     run_in_background($config, sub {
712         # We need to get the data in the paste buffer before we can paste
713         # it.
714         handler_yank($screen, $config, $match);
715
716         # Sleep until we switch back to the current window.
717         Time::HiRes::usleep($config->{setting}{paste_sleep});
718
719         run_command($config, \@cmd);
720     });
721     return;
722 }
723 sub handler_url {
724     my ($screen, $config, $match) = @_;
725
726     debug $config, 'handler_url', 'started';
727
728     run_in_background($config, sub {
729         my $url = defined $match->{url}
730                 ? $match->{url}
731                 : $match->{string};
732
733         my @cmd = map { $screen->encode($_) } (
734             @{$config->{setting}{browser}},
735             $url,
736         );
737         run_command($config, \@cmd);
738     });
739     return;
740 }
741
742
743
744 # CONFIGURATION DEFAULTS
745
746 =head1 CONFIGURATION
747
748 fcscs is configured through F<~/.fcscsrc> or F<~/.config/fcscs/fcscsrc> which
749 is a normal Perl script with all of Perl's usual features.
750
751 All configuration values are stored in the hash C<%config>. All manually
752 defined keys overwrite the default settings.
753
754 A simple F<~/.fcscsrc> could look like this (for details about the used
755 settings see below):
756
757     use strict;
758     use warnings;
759
760     use Curses; # for COLOR_* and A_* constants
761
762     our %config;
763
764     # Draw matches in blue.
765     $config{attribute}{match_string} = color_pair(COLOR_BLUE, -1);
766     # Enable Vim-like 'smartcase', ignore case until an upper character is
767     # searched.
768     $config{setting}{smartcase} = 1;
769
770     # Use chromium to open URLs if running under X, elinks otherwise.
771     if (defined $ENV{DISPLAY}) {
772         $config{setting}{browser} = ['chromium'];
773     } else {
774         $config{setting}{browser} = ['elinks'];
775     }
776
777     # Let fcscs know the file was loaded successfully.
778     1;
779
780 =cut
781
782
783 if (@ARGV != 1) {
784     require Pod::Usage;
785     Pod::Usage::pod2usage(2);
786 }
787
788
789 # Determine terminal encoding from the environment ($ENV{LANG} or similar).
790 my $encoding = I18N::Langinfo::langinfo(I18N::Langinfo::CODESET);
791
792 my $screen = Screen->init($encoding);
793
794 # We must restore the screen before exiting.
795 local $SIG{INT} = sub {
796     $screen->deinit;
797     exit 128 + 2;
798 };
799 # Make all warnings fatal to make sure they don't get lost (stderr is normally
800 # not displayed).
801 local $SIG{__WARN__} = sub {
802     $screen->die('warning', @_);
803 };
804
805
806
807 =head2 MAPPINGS
808
809 I<NOTE>: Mappings are split in two categories: Mode mappings which change the
810 selection and may receive additional input (e.g. a search string) and simple
811 mappings which only change some value. Mode mappings are configured via
812 C<$config{mapping}{mode}>, simple mappings via C<$config{mapping}{simple}>.
813
814 The following mode mappings are available by default (the function to remap
815 them in parentheses):
816
817 =over
818
819 =item B<f> select absolute/relative paths (C<\&mapping_mode_path>)
820
821 =item B<u> select URLs (C<\&mapping_mode_url>)
822
823 =item B</> search for regex to get selection (C<\&mapping_mode_search>)
824
825 =item B<q> quit fcscs (C<\&mapping_quit>)
826
827 =back
828
829 The following simple mappings are available by default:
830
831 =over
832
833 =item B<p> enable pasting (C<\&mapping_paste>)
834
835 =item B<y> enable yanking (copying) (C<\&mapping_yank>)
836
837 =back
838
839 All (single-byte) keys except numbers, backspace and return can be mapped.
840
841 Unknown mappings are ignored when pressing keys.
842
843 To remove a default mapping, delete it from the mapping hash.
844
845 Example:
846
847     # Map 'p' to select paths, 'P' to enable pasting.
848     $config{mapping}{mode}{p} = \&mapping_mode_path;
849     $config{mapping}{simple}{P} = \&mapping_paste;
850
851     # Disable 'f' mapping.
852     delete $config{mapping}{mode}{f};
853
854 =cut
855 my %mapping_mode = (
856     f   => \&mapping_mode_path,
857     u   => \&mapping_mode_url,
858     '/' => \&mapping_mode_search,
859     q   => \&mapping_quit,
860 );
861 my %mapping_simple = (
862     p => \&mapping_paste,
863     y => \&mapping_yank,
864 );
865
866 =head2 ATTRIBUTES
867
868 Attributes are used to style the output. They must be Curses attributes.
869 Defaults in parentheses (foreground, background, attribute).
870
871 =over
872
873 =item B<match_id>      attribute for match numbers (red, default, bold)
874
875 =item B<match_string>  attribute for matches (yellow, default, normal)
876
877 =item B<prompt_name>   attribute for prompt name (standout)
878
879 =item B<prompt_flags>  attribute for prompt flags (standout)
880
881 =back
882
883 Example:
884
885     # Draw prompt flags in bold red with default background color.
886     $config{attribute}{prompt_flags}
887         = Curses::A_BOLD
888         | color_pair(Curses::COLOR_RED, -1);
889
890 =cut
891 my %attribute = (
892     match_id     => $screen->color_pair(Curses::COLOR_RED, -1)
893                     | Curses::A_BOLD,
894     match_string => $screen->color_pair(Curses::COLOR_YELLOW, -1),
895     prompt_name  => Curses::A_STANDOUT,
896     prompt_flags => Curses::A_STANDOUT,
897 );
898
899 =head2 SETTINGS
900
901 Defaults in parentheses.
902
903 =over
904
905 =item B<debug>          enable debug mode (redirect stderr when enabling) (C<0>)
906
907 =item B<initial_mode>   start in this mode, must be a valid mode mapping (C<\&mapping_mode_url>)
908
909 =item B<multiplexer>    set multiplexer ("screen" or "tmux") if not autodetected (C<undef>)
910
911 =item B<ignorecase>     ignore case when searching (C<0>)
912
913 =item B<smartcase>      ignore case unless one uppercase character is searched (C<1>)
914
915 =item B<paste_sleep>    sleep x us before running paste command (C<100_000>)
916
917 =item B<screen_msgwait> GNU Screen's msgwait variable, used when yanking (C<5>)
918
919 =item B<browser>        browser command as array reference (C<['x-www-browser']>)
920
921 =back
922
923 Example:
924
925     # Select paths on startup instead of URLs.
926     $config{setting}{initial_mode} = \&mapping_mode_path;
927
928 =cut
929 my %setting = (
930     # options
931     debug          => 0,
932     initial_mode   => \&mapping_mode_url,
933     multiplexer    => undef,
934     ignorecase     => 0,
935     smartcase      => 1,
936     paste_sleep    => 100_000,
937     screen_msgwait => 5,
938     # commands
939     browser        => ['x-www-browser'],
940 );
941
942 =head2 REGEXPS
943
944 =over
945
946 =item B<url> used by C<\&mapping_mode_url()>
947
948 =item B<path> used by C<\&mapping_mode_path()>
949
950 =back
951
952 Example:
953
954     # Select all non-whitespace characters when searching for paths.
955     $config{regex}{path} = qr{\S+};
956
957 =cut
958 my %regex = (
959     # Taken from urlview's default configuration file, thanks.
960     url  => qr{((?:(?:(?:http|https|ftp|gopher)|mailto):(?://)?[^ <>"\t]*|(?:www|ftp)[0-9]?\.[-a-z0-9.]+)[^ .,;\t\n\r<">\):]?[^, <>"\t]*[^ .,;\t\n\r<">\):])},
961     path => qr{(~?[a-zA-Z0-9_./-]*/[a-zA-Z0-9_./-]+)},
962 );
963
964 my %state = (
965     initial => 1, # used by select_match() for 'initial_mode'
966     handler => undef,
967 );
968
969
970
971 # CONFIGURATION "API"
972
973 =head2 FUNCTIONS
974
975 The following functions are available:
976
977     color_pair($fg, $bg)
978
979 Create a new Curses attribute with the given fore- and background color.
980
981     mapping_mode_path()
982     mapping_mode_url()
983     mapping_mode_search()
984
985     mapping_paste()
986     mapping_yank()
987     mapping_quit()
988
989 Used as mappings, see L</MAPPINGS> above.
990
991     handler_yank()
992     handler_paste()
993     handler_url()
994
995 Used as handler to yank, paste selection or open URL in browser. They are
996 either set by the matching mapping function (C<mapping_paste()>, etc.) or
997 configured by the current mode.
998
999     get_regex_matches()
1000     select_match()
1001     run_command()
1002     run_in_background()
1003
1004 Helper functions when writing custom mappings, see the source for details.
1005
1006 Example:
1007
1008     TODO
1009
1010 =cut
1011
1012 # All variables and functions which are usable by ~/.fcscsrc.
1013 package Fcscs {
1014     our $screen; # "private"
1015     our %config;
1016
1017     sub color_pair { return $screen->color_pair(@_); }
1018
1019     sub mapping_mode_path { return main::mapping_mode_path(@_); }
1020     sub mapping_mode_url { return main::mapping_mode_url(@_); }
1021     sub mapping_mode_search { return main::mapping_mode_search(@_); }
1022
1023     sub mapping_paste { return main::mapping_paste(@_); }
1024     sub mapping_yank { return main::mapping_yank(@_); }
1025     sub mapping_quit { return main::mapping_quit(@_); }
1026
1027     sub handler_yank { return main::handler_yank(@_); }
1028     sub handler_paste { return main::handler_paste(@_); }
1029     sub handler_url { return main::handler_url(@_); }
1030
1031     sub get_regex_matches { return main::get_regex_matches(@_); }
1032     sub select_match { return main::select_match(@_); }
1033
1034     sub run_command { return main::run_command(@_); }
1035     sub run_in_background { return main::run_in_background(@_); }
1036 }
1037 $Fcscs::screen = $screen;
1038
1039
1040
1041 # LOAD USER CONFIG
1042
1043 # Alias %config and %Fcscs::config. %config is less to type.
1044 our %config;
1045 local *config = \%Fcscs::config;
1046
1047 $config{mapping}{mode}   = \%mapping_mode;
1048 $config{mapping}{simple} = \%mapping_simple;
1049 $config{attribute}       = \%attribute;
1050 $config{setting}         = \%setting;
1051 $config{regex}           = \%regex;
1052 $config{state}           = \%state;
1053
1054 package Fcscs {
1055     my @configs = ("$ENV{HOME}/.fcscsrc",
1056                    "$ENV{HOME}/.config/fcscs/fcscsrc");
1057     foreach my $path (@configs) {
1058         my $decoded = $screen->decode($path);
1059
1060         # Load configuration file. Checks have a race condition if the home
1061         # directory is writable by an attacker (but then the user is screwed
1062         # anyway).
1063         next unless -e $path;
1064         if (not -O $path) {
1065             $screen->die("Config '$decoded' not owned by current user!");
1066         }
1067         # Make sure the file is not writable by other users. Doesn't handle
1068         # ACLs and see comment above about race conditions.
1069         my @stat = stat $path or die $!;
1070         my $mode = $stat[2];
1071         if (($mode & Fcntl::S_IWGRP) or ($mode & Fcntl::S_IWOTH)) {
1072             die "Config '$decoded' must not be writable by other users.";
1073         }
1074
1075         my $result = do $path;
1076         if (not $result) {
1077             $screen->die("Failed to parse '$decoded': $@") if $@;
1078             $screen->die("Failed to do '$decoded': $!") unless defined $result;
1079             $screen->die("Failed to run '$decoded'.");
1080         }
1081
1082         last; # success, don't load more files
1083     }
1084 }
1085 $screen->{debug} = $config{setting}{debug};
1086
1087
1088 # MAIN
1089
1090 eval {
1091     # Auto-detect current multiplexer.
1092     if (not defined $config{setting}{multiplexer}) {
1093         if (defined $ENV{STY} and defined $ENV{TMUX}) {
1094             die 'Found both $STY and $TMUX, set $config{setting}{multiplexer}.';
1095         } elsif (defined $ENV{STY}) {
1096             $config{setting}{multiplexer} = 'screen';
1097         } elsif (defined $ENV{TMUX}) {
1098             $config{setting}{multiplexer} = 'tmux';
1099         } else {
1100             die 'No multiplexer found.';
1101         }
1102     }
1103
1104     my $binmode = $encoding;
1105     # GNU screen stores the screen dump for unknown reasons as ISO-8859-1
1106     # instead of the currently active encoding.
1107     if ($config{setting}{multiplexer} eq 'screen') {
1108         $binmode = 'ISO-8859-1';
1109     }
1110
1111     my @input_lines;
1112     open my $fh, '<', $ARGV[0] or die $!;
1113     binmode $fh, ":encoding($binmode)" or die $!;
1114     while (<$fh>) {
1115         chomp;
1116         push @input_lines, $_;
1117     }
1118     close $fh or die $!;
1119
1120     my $input = prepare_input($screen, \@input_lines);
1121
1122     # Display original screen content.
1123     my $y = 0;
1124     foreach (@{$input->{lines}}) {
1125         $screen->draw_simple($y++, 0, undef, $_);
1126     }
1127     $screen->refresh;
1128
1129
1130     my $mapping = $config{setting}{initial_mode};
1131
1132     my $key;
1133     while (1) {
1134         if (not defined $mapping) {
1135             $key = $screen->getch unless defined $key;
1136             debug \%config, 'input', "got key '$key'";
1137
1138             $mapping = $config{mapping}{mode}{$key};
1139             $mapping = $config{mapping}{simple}{$key} unless defined $mapping;
1140             if (not defined $mapping) { # ignore unknown mappings
1141                 $key = undef;
1142                 next;
1143             }
1144         }
1145
1146         debug \%config, 'input', 'running mapping';
1147         my $result = $mapping->($key, $screen, \%config, $input);
1148         $mapping = undef;
1149
1150 RESULT:
1151         if (defined $result->{quit}) {
1152             debug \%config, 'input', 'quitting';
1153             last;
1154         }
1155         if (defined $result->{key}) {
1156             $key = $result->{key}; # lookup another mapping
1157             debug \%config, 'input', "processing new key: '$key'";
1158             next;
1159         }
1160         if (defined $result->{select}) {
1161             debug \%config, 'input', 'selecting match';
1162             my $tmp = $result;
1163             $result = select_match($result->{select},
1164                                 $screen, \%config, $input,
1165                                 $result->{matches});
1166             $result->{handler} = $tmp->{handler};
1167             goto RESULT; # reprocess special entries in result
1168         }
1169         if (defined $result->{match}) {
1170             debug \%config, 'input', 'running handler';
1171             my $handler = $config{state}{handler};                 # set by user
1172             $handler = $result->{handler} unless defined $handler; # set by mapping
1173             $handler = \&handler_yank     unless defined $handler; # fallback
1174             $handler->($screen, \%config, $result->{match});
1175             last;
1176         }
1177
1178         $key = undef; # get next key from user
1179     }
1180 };
1181 if ($@) {
1182     $screen->die("$@");
1183 }
1184
1185 $screen->deinit;
1186
1187 __END__
1188
1189 =head1 EXIT STATUS
1190
1191 =over 4
1192
1193 =item B<0>
1194
1195 Success.
1196
1197 =item B<1>
1198
1199 An error occurred.
1200
1201 =item B<2>
1202
1203 Invalid arguments/options.
1204
1205 =back
1206
1207 =head1 AUTHOR
1208
1209 Simon Ruderich E<lt>simon@ruderich.orgE<gt>
1210
1211 =head1 LICENSE AND COPYRIGHT
1212
1213 Copyright (C) 2013-2016 by Simon Ruderich
1214
1215 This program is free software: you can redistribute it and/or modify
1216 it under the terms of the GNU General Public License as published by
1217 the Free Software Foundation, either version 3 of the License, or
1218 (at your option) any later version.
1219
1220 This program is distributed in the hope that it will be useful,
1221 but WITHOUT ANY WARRANTY; without even the implied warranty of
1222 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1223 GNU General Public License for more details.
1224
1225 You should have received a copy of the GNU General Public License
1226 along with this program.  If not, see E<lt>http://www.gnu.org/licenses/E<gt>.
1227
1228 =head1 SEE ALSO
1229
1230 L<screen(1)>, L<tmux(1)>, L<urlview(1)>
1231
1232 =cut