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