]> ruderich.org/simon Gitweb - fcscs/fcscs.git/blob - bin/fcscs
95a769a40d0225ade2fd7b0a2c205500e80ff60e
[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, offset => $offset, 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     $screen->draw_matches($config, $matches, []); # remove matches
547
548     foreach (@{$matches}) {
549         return { match => $_ } if $_->{id} == $number;
550     }
551     debug $config, 'select_match', 'no match selected';
552     return { match => undef };
553 }
554
555 sub extend_match_regex_left {
556     my ($line, $match, $regex) = @_;
557
558     my $s = reverse substr $line, 0, $match->{x};
559     if ($s =~ /^($regex)/) {
560         $match->{string}  = reverse($1) . $match->{string};
561         $match->{x}      -= length $1;
562         $match->{offset} -= length $1;
563     }
564     return;
565 }
566 sub extend_match_regex_right {
567     my ($line, $match, $regex) = @_;
568
569     my $s = substr $line, $match->{x} + length $match->{string};
570     if ($s =~ /^($regex)/) {
571         $match->{string} .= $1;
572     }
573     return;
574 }
575 sub extend_match {
576     my ($screen, $config, $input, $match) = @_;
577
578     debug $config, 'extend_match', 'started';
579
580     $screen->prompt(name => 'extend', value => undef);
581     $screen->draw_prompt($config);
582
583     delete $match->{id}; # don't draw any match ids
584     $screen->draw_matches($config, [], [$match]);
585     $screen->refresh;
586
587     my $line = $input->{lines}[$match->{y}];
588
589     while (1) {
590         my $match_old = \%{$match};
591
592         my $char = $screen->getch;
593         if ($char eq "\n") { # accept match
594             last;
595
596         } elsif ($char eq 'w') { # select current word (both directions)
597             extend_match_regex_left($line,  $match, qr/\w+/);
598             extend_match_regex_right($line, $match, qr/\w+/);
599         } elsif ($char eq 'b') { # select current word (only left)
600             extend_match_regex_left($line,  $match, qr/\w+/);
601         } elsif ($char eq 'e') { # select current word (only right)
602             extend_match_regex_right($line, $match, qr/\w+/);
603
604         } elsif ($char eq 'W') { # select current WORD (both directions)
605             extend_match_regex_left($line,  $match, qr/\S+/);
606             extend_match_regex_right($line, $match, qr/\S+/);
607         } elsif ($char eq 'B') { # select current WORD (only left)
608             extend_match_regex_left($line,  $match, qr/\S+/);
609         } elsif ($char eq 'E') { # select current WORD (only right)
610             extend_match_regex_right($line, $match, qr/\S+/);
611
612         } elsif ($char eq '^') { # select to beginning of line
613             extend_match_regex_left($line, $match, qr/.+/);
614         } elsif ($char eq '$') { # select to end of line
615             extend_match_regex_right($line, $match, qr/.+/);
616
617         # Allow mode changes if not overwritten by local mappings.
618         } elsif (defined $config->{mapping}{mode}{$char}) {
619             $screen->draw_matches($config, [$match_old], []); # clear match
620             return { key => $char };
621
622         } else {
623             next; # ignore unknown mappings
624         }
625
626         $screen->draw_matches($config, [$match_old], [$match]);
627         $screen->refresh;
628     }
629
630     debug $config, 'extend_match', 'done';
631
632     return { match => $match };
633 }
634
635
636 sub mapping_paste {
637     my ($key, $screen, $config, $input) = @_;
638
639     debug $config, 'mapping_paste', 'started';
640
641     $config->{state}{handler} = $config->{handler}{paste};
642
643     $screen->prompt(flags => 'P'); # paste
644     $screen->draw_prompt($config);
645     $screen->refresh;
646
647     return {};
648 }
649 sub mapping_yank {
650     my ($key, $screen, $config, $input) = @_;
651
652     debug $config, 'mapping_yank', 'started';
653
654     $config->{state}{handler} = $config->{handler}{yank};
655
656     $screen->prompt(flags => 'Y'); # yank
657     $screen->draw_prompt($config);
658     $screen->refresh;
659
660     return {};
661 }
662
663
664 sub mapping_mode_path {
665     my ($key, $screen, $config, $input) = @_;
666
667     debug $config, 'mapping_mode_path', 'started';
668
669     my @matches = get_regex_matches($input, $config->{regex}{path});
670     return {
671         select  => 'path select',
672         matches => \@matches,
673         handler => $config->{handler}{yank},
674     };
675 }
676 sub mapping_mode_url {
677     my ($key, $screen, $config, $input) = @_;
678
679     debug $config, 'mapping_mode_url', 'started';
680
681     my @matches = get_regex_matches($input, $config->{regex}{url});
682     return {
683         select  => 'url select',
684         matches => \@matches,
685         handler => $config->{handler}{url},
686     };
687 }
688
689 sub mapping_mode_search {
690     my ($key, $screen, $config, $input) = @_;
691
692     debug $config, 'mapping_mode_search', 'started';
693
694     $screen->cursor(1);
695
696     my $search = ''; # encoded
697     my @last_matches;
698     while (1) {
699         # getch doesn't return decoded characters but raw input bytes. Wait
700         # until the input character is complete.
701         my $value = $screen->decode($search);
702         $value = '' unless defined $value; # undef on decode failure
703
704         $screen->prompt(name => 'search', value => $value);
705         $screen->draw_prompt($config);
706         $screen->refresh;
707
708         my $char = $screen->getch;
709         # TODO: readline editing support
710         if ($char eq "\n") {
711             last;
712         } elsif ($char eq "\b" or $char eq "\x7f") { # backspace
713             # Remove a character, not a byte.
714             $search = $screen->decode($search);
715             chop $search;
716             $search = $screen->encode($search);
717         } else {
718             $search .= $char;
719             next unless defined $screen->decode($search);
720         }
721
722         my @matches;
723         if ($search ne '') {
724             my $case = '';
725             if (($config->{setting}{smartcase} and $search eq lc $search)
726                     or $config->{setting}{ignorecase}) {
727                 $case = '(?i)';
728             }
729             # Ignore invalid regexps.
730             # TODO: display warning on error?
731             eval {
732                 @matches = get_regex_matches($input, qr/($case$search)/);
733             };
734         }
735         $screen->draw_matches($config, \@last_matches, \@matches);
736         @last_matches = @matches;
737     }
738
739     $screen->cursor(0);
740
741     return {
742         select  => 'search',
743         matches => \@last_matches,
744         extend  => 1,
745         handler => $config->{handler}{yank},
746     };
747 }
748
749 sub mapping_quit {
750     my ($key, $screen, $config, $input) = @_;
751
752     # Key is necessary to fall through to main event loop which then quits.
753     return { key => $key, quit => 1 };
754 }
755
756
757 sub handler_yank {
758     my ($screen, $config, $match) = @_;
759
760     debug $config, 'handler_yank', 'started';
761
762     require File::Temp;
763
764     # Use a temporary file to prevent leaking the yanked data to other users
765     # with the command line, e.g. ps aux or top.
766     my ($fh, $tmp) = File::Temp::tempfile(); # dies on its own
767     print $fh $screen->encode($match->{value});
768     close $fh or die $!;
769
770     if ($config->{setting}{multiplexer} eq 'screen') {
771         debug $config, 'handler_yank', 'using screen';
772
773         # GNU screen displays an annoying "Slurping X characters into buffer".
774         # Use 'msgwait 0' as a hack to disable it.
775         my $msgwait = $config->{setting}{screen_msgwait};
776         run_command($config, ['screen', '-X', 'msgwait', 0]);
777         run_command($config, ['screen', '-X', 'readbuf', $tmp]);
778         run_command($config, ['screen', '-X', 'msgwait', $msgwait]);
779     } elsif ($config->{setting}{multiplexer} eq 'tmux') {
780         debug $config, 'handler_yank', 'using tmux';
781
782         run_command($config, ['tmux', 'load-buffer', $tmp]);
783     } else {
784         die 'unsupported multiplexer';
785     }
786
787     unlink $tmp or die $!;
788     return;
789 }
790 sub handler_paste {
791     my ($screen, $config, $match) = @_;
792
793     debug $config, 'handler_paste', 'started';
794
795     require Time::HiRes;
796
797     my @cmd;
798     if ($config->{setting}{multiplexer} eq 'screen') {
799         debug $config, 'handler_paste', 'using screen';
800         @cmd = qw( screen -X paste . );
801     } elsif ($config->{setting}{multiplexer} eq 'tmux') {
802         debug $config, 'handler_paste', 'using tmux';
803         @cmd = qw( tmux paste-buffer );
804     } else {
805         die 'unsupported multiplexer';
806     }
807
808     run_in_background($config, sub {
809         # We need to get the data in the paste buffer before we can paste
810         # it.
811         handler_yank($screen, $config, $match);
812
813         # Sleep until we switch back to the current window.
814         Time::HiRes::usleep($config->{setting}{paste_sleep});
815
816         run_command($config, \@cmd);
817     });
818     return;
819 }
820 sub handler_url {
821     my ($screen, $config, $match) = @_;
822
823     debug $config, 'handler_url', "opening $match->{value}";
824
825     run_in_background($config, sub {
826         my @cmd = map { $screen->encode($_) } (
827             @{$config->{setting}{browser}},
828             $match->{value},
829         );
830         run_command($config, \@cmd);
831     });
832     return;
833 }
834
835
836
837 # CONFIGURATION DEFAULTS
838
839 =head1 CONFIGURATION
840
841 fcscs is configured through F<~/.fcscsrc> or F<~/.config/fcscs/fcscsrc> which
842 is a normal Perl script with all of Perl's usual features.
843
844 All configuration values are stored in the hash C<%config>. All manually
845 defined keys overwrite the default settings.
846
847 A simple F<~/.fcscsrc> could look like this (for details about the used
848 settings see below):
849
850     use strict;
851     use warnings;
852
853     use Curses; # for COLOR_* and A_* constants
854
855     our %config;
856
857     # Draw matches in blue.
858     $config{attribute}{match_string} = color_pair(COLOR_BLUE, -1);
859     # Enable Vim-like 'smartcase', ignore case until an upper character is
860     # searched.
861     $config{setting}{smartcase} = 1;
862
863     # Use chromium to open URLs if running under X, elinks otherwise.
864     if (defined $ENV{DISPLAY}) {
865         $config{setting}{browser} = ['chromium'];
866     } else {
867         $config{setting}{browser} = ['elinks'];
868     }
869
870     # Let fcscs know the file was loaded successfully.
871     1;
872
873 =cut
874
875
876 if (@ARGV != 1) {
877     require Pod::Usage;
878     Pod::Usage::pod2usage(2);
879 }
880
881
882 # Determine terminal encoding from the environment ($ENV{LANG} or similar).
883 my $encoding = I18N::Langinfo::langinfo(I18N::Langinfo::CODESET);
884
885 my $screen = Screen->init($encoding);
886
887 # We must restore the screen before exiting.
888 local $SIG{INT} = sub {
889     $screen->deinit;
890     exit 128 + 2;
891 };
892 # Make all warnings fatal to make sure they don't get lost (stderr is normally
893 # not displayed).
894 local $SIG{__WARN__} = sub {
895     $screen->die('warning', @_);
896 };
897
898
899
900 =head2 MAPPINGS
901
902 I<NOTE>: Mappings are split in two categories: Mode mappings which change the
903 selection and may receive additional input (e.g. a search string) and simple
904 mappings which only change some value. Mode mappings are configured via
905 C<$config{mapping}{mode}>, simple mappings via C<$config{mapping}{simple}>.
906
907 The following mode mappings are available by default (the function to remap
908 them in parentheses):
909
910 =over
911
912 =item B<f> select absolute/relative paths (C<\&mapping_mode_path>)
913
914 =item B<u> select URLs (C<\&mapping_mode_url>)
915
916 =item B</> search for regex to get selection (C<\&mapping_mode_search>)
917
918 =item B<q> quit fcscs (C<\&mapping_quit>)
919
920 =back
921
922 The following simple mappings are available by default:
923
924 =over
925
926 =item B<p> enable pasting (C<\&mapping_paste>)
927
928 =item B<y> enable yanking (copying) (C<\&mapping_yank>)
929
930 =back
931
932 All (single-byte) keys except numbers, backspace and return can be mapped.
933
934 Unknown mappings are ignored when pressing keys.
935
936 To remove a default mapping, delete it from the mapping hash.
937
938 Example:
939
940     # Map 'p' to select paths, 'P' to enable pasting.
941     $config{mapping}{mode}{p} = \&mapping_mode_path;
942     $config{mapping}{simple}{P} = \&mapping_paste;
943
944     # Disable 'f' mapping.
945     delete $config{mapping}{mode}{f};
946
947 =cut
948 my %mapping_mode = (
949     f   => \&mapping_mode_path,
950     u   => \&mapping_mode_url,
951     '/' => \&mapping_mode_search,
952     q   => \&mapping_quit,
953 );
954 my %mapping_simple = (
955     p => \&mapping_paste,
956     y => \&mapping_yank,
957 );
958
959 =head2 ATTRIBUTES
960
961 Attributes are used to style the output. They must be Curses attributes.
962 Defaults in parentheses (foreground, background, attribute).
963
964 =over
965
966 =item B<match_id>      attribute for match numbers (red, default, bold)
967
968 =item B<match_string>  attribute for matches (yellow, default, normal)
969
970 =item B<prompt_name>   attribute for prompt name (standout)
971
972 =item B<prompt_flags>  attribute for prompt flags (standout)
973
974 =back
975
976 Example:
977
978     # Draw prompt flags in bold red with default background color.
979     $config{attribute}{prompt_flags}
980         = Curses::A_BOLD
981         | color_pair(Curses::COLOR_RED, -1);
982
983 =cut
984 my %attribute = (
985     match_id     => $screen->color_pair(Curses::COLOR_RED, -1)
986                     | Curses::A_BOLD,
987     match_string => $screen->color_pair(Curses::COLOR_YELLOW, -1),
988     prompt_name  => Curses::A_STANDOUT,
989     prompt_flags => Curses::A_STANDOUT,
990 );
991
992 =head2 SETTINGS
993
994 Defaults in parentheses.
995
996 =over
997
998 =item B<debug>          enable debug mode, writes to I<~/.config/fcscs/log> (C<0>)
999
1000 =item B<initial_mode>   start in this mode, must be a valid mode mapping (C<\&mapping_mode_url>)
1001
1002 =item B<multiplexer>    set multiplexer ("screen" or "tmux") if not autodetected (C<undef>)
1003
1004 =item B<ignorecase>     ignore case when searching (C<0>)
1005
1006 =item B<smartcase>      ignore case unless one uppercase character is searched (C<1>)
1007
1008 =item B<paste_sleep>    sleep x us before running paste command (C<100_000>)
1009
1010 =item B<screen_msgwait> GNU Screen's msgwait variable, used when yanking (C<5>)
1011
1012 =item B<browser>        browser command as array reference (C<['x-www-browser']>)
1013
1014 =back
1015
1016 Example:
1017
1018     # Select paths on startup instead of URLs.
1019     $config{setting}{initial_mode} = \&mapping_mode_path;
1020
1021 =cut
1022 my %setting = (
1023     # options
1024     debug          => 0,
1025     initial_mode   => \&mapping_mode_url,
1026     multiplexer    => undef,
1027     ignorecase     => 0,
1028     smartcase      => 1,
1029     paste_sleep    => 100_000,
1030     screen_msgwait => 5,
1031     # commands
1032     browser        => ['x-www-browser'],
1033 );
1034
1035 =head2 REGEXPS
1036
1037 =over
1038
1039 =item B<url> used by C<\&mapping_mode_url()>
1040
1041 =item B<path> used by C<\&mapping_mode_path()>
1042
1043 =back
1044
1045 Example:
1046
1047     # Select all non-whitespace characters when searching for paths.
1048     $config{regex}{path} = qr{(\S+)};
1049
1050 =cut
1051 my %regex = (
1052     # Taken from urlview's default configuration file, thanks.
1053     url  => qr{((?:(?:(?:http|https|ftp|gopher)|mailto):(?://)?[^ <>"\t]*|(?:www|ftp)[0-9]?\.[-a-z0-9.]+)[^ .,;\t\n\r<">\):]?[^, <>"\t]*[^ .,;\t\n\r<">\):])},
1054     path => qr{(~?[a-zA-Z0-9_./-]*/[a-zA-Z0-9_./-]+)},
1055 );
1056
1057 =head2 HANDLERS
1058
1059 Handlers are used to perform actions on the selected string.
1060
1061 The following handlers are available, defaults in parentheses.
1062
1063 =over
1064
1065 =item B<yank>  used to yank (copy) selection to paste buffer (C<\&handler_yank>)
1066
1067 =item B<paste> used to paste selection into window (C<\&handler_paste>)
1068
1069 =item B<url>   used to open URLs (e.g. in a browser) (C<\&handler_url>)
1070
1071 =back
1072
1073 Example:
1074
1075     # Download YouTube videos with a custom wrapper, handle all other URLs
1076     # with the default URL handler.
1077     $config{handler}{url} = sub {
1078         my ($screen, $config, $match) = @_;
1079
1080         if ($match->{value} =~ m{^https://www.youtube.com/}) {
1081             return run_in_background($config, sub {
1082                 run_command($config, ['youtube-dl-wrapper', $match->{value}]);
1083             });
1084         }
1085         handler_url(@_);
1086     };
1087
1088 =cut
1089 my %handler = (
1090     yank  => \&handler_yank,
1091     paste => \&handler_paste,
1092     url   => \&handler_url,
1093 );
1094
1095 my %state = (
1096     initial => 1, # used by select_match() for 'initial_mode'
1097     handler => undef,
1098 );
1099
1100
1101
1102 # CONFIGURATION "API"
1103
1104 =head2 FUNCTIONS
1105
1106 The following functions are available:
1107
1108     color_pair($fg, $bg)
1109
1110 Create a new Curses attribute with the given fore- and background color.
1111
1112     mapping_mode_path()
1113     mapping_mode_url()
1114     mapping_mode_search()
1115
1116     mapping_paste()
1117     mapping_yank()
1118     mapping_quit()
1119
1120 Used as mappings, see L</MAPPINGS> above.
1121
1122     handler_yank()
1123     handler_paste()
1124     handler_url()
1125
1126 Used as handler to yank, paste selection or open URL in browser.
1127
1128     debug()
1129     get_regex_matches()
1130     select_match()
1131     run_command()
1132     run_in_background()
1133
1134 Helper functions when writing custom mappings, see the source for details.
1135
1136 Example:
1137
1138     TODO
1139
1140 =cut
1141
1142 # All variables and functions which are usable by ~/.fcscsrc.
1143 package Fcscs {
1144     our $screen; # "private"
1145     our %config;
1146
1147     sub color_pair { return $screen->color_pair(@_); }
1148
1149     sub mapping_mode_path { return main::mapping_mode_path(@_); }
1150     sub mapping_mode_url { return main::mapping_mode_url(@_); }
1151     sub mapping_mode_search { return main::mapping_mode_search(@_); }
1152
1153     sub mapping_paste { return main::mapping_paste(@_); }
1154     sub mapping_yank { return main::mapping_yank(@_); }
1155     sub mapping_quit { return main::mapping_quit(@_); }
1156
1157     sub handler_yank { return main::handler_yank(@_); }
1158     sub handler_paste { return main::handler_paste(@_); }
1159     sub handler_url { return main::handler_url(@_); }
1160
1161     sub debug { return main::debug(@_); }
1162
1163     sub get_regex_matches { return main::get_regex_matches(@_); }
1164     sub select_match { return main::select_match(@_); }
1165
1166     sub run_command { return main::run_command(@_); }
1167     sub run_in_background { return main::run_in_background(@_); }
1168 }
1169 $Fcscs::screen = $screen;
1170
1171
1172
1173 # LOAD USER CONFIG
1174
1175 # Alias %config and %Fcscs::config. %config is less to type.
1176 our %config;
1177 local *config = \%Fcscs::config;
1178
1179 $config{mapping}{mode}   = \%mapping_mode;
1180 $config{mapping}{simple} = \%mapping_simple;
1181 $config{attribute}       = \%attribute;
1182 $config{setting}         = \%setting;
1183 $config{regex}           = \%regex;
1184 $config{handler}         = \%handler;
1185 $config{state}           = \%state;
1186
1187 package Fcscs {
1188     my @configs = ("$ENV{HOME}/.fcscsrc",
1189                    "$ENV{HOME}/.config/fcscs/fcscsrc");
1190     foreach my $path (@configs) {
1191         my $decoded = $screen->decode($path);
1192
1193         # Load configuration file. Checks have a race condition if the home
1194         # directory is writable by an attacker (but then the user is screwed
1195         # anyway).
1196         next unless -e $path;
1197         if (not -O $path) {
1198             $screen->die("Config '$decoded' not owned by current user!");
1199         }
1200         # Make sure the file is not writable by other users. Doesn't handle
1201         # ACLs and see comment above about race conditions.
1202         my @stat = stat $path or die $!;
1203         my $mode = $stat[2];
1204         if (($mode & Fcntl::S_IWGRP) or ($mode & Fcntl::S_IWOTH)) {
1205             die "Config '$decoded' must not be writable by other users.";
1206         }
1207
1208         my $result = do $path;
1209         if (not $result) {
1210             $screen->die("Failed to parse '$decoded': $@") if $@;
1211             $screen->die("Failed to do '$decoded': $!") unless defined $result;
1212             $screen->die("Failed to run '$decoded'.");
1213         }
1214
1215         last; # success, don't load more files
1216     }
1217 }
1218 $screen->{debug} = $config{setting}{debug};
1219
1220
1221 # MAIN
1222
1223 eval {
1224     # Auto-detect current multiplexer.
1225     if (not defined $config{setting}{multiplexer}) {
1226         if (defined $ENV{STY} and defined $ENV{TMUX}) {
1227             die 'Found both $STY and $TMUX, set $config{setting}{multiplexer}.';
1228         } elsif (defined $ENV{STY}) {
1229             $config{setting}{multiplexer} = 'screen';
1230         } elsif (defined $ENV{TMUX}) {
1231             $config{setting}{multiplexer} = 'tmux';
1232         } else {
1233             die 'No multiplexer found.';
1234         }
1235     }
1236
1237     my $binmode = $encoding;
1238     # GNU screen stores the screen dump for unknown reasons as ISO-8859-1
1239     # instead of the currently active encoding.
1240     if ($config{setting}{multiplexer} eq 'screen') {
1241         $binmode = 'ISO-8859-1';
1242     }
1243
1244     my @input_lines;
1245     open my $fh, '<', $ARGV[0] or die $!;
1246     binmode $fh, ":encoding($binmode)" or die $!;
1247     while (<$fh>) {
1248         chomp;
1249         push @input_lines, $_;
1250     }
1251     close $fh or die $!;
1252
1253     my $input = prepare_input($screen, \@input_lines);
1254
1255     # Display original screen content.
1256     my $y = 0;
1257     foreach (@{$input->{lines}}) {
1258         $screen->draw_simple($y++, 0, undef, $_);
1259     }
1260     $screen->refresh;
1261
1262
1263     my $mapping = $config{setting}{initial_mode};
1264
1265     my $key;
1266     while (1) {
1267         if (not defined $mapping) {
1268             $key = $screen->getch unless defined $key;
1269             debug \%config, 'input', "got key '$key'";
1270
1271             $mapping = $config{mapping}{mode}{$key};
1272             $mapping = $config{mapping}{simple}{$key} unless defined $mapping;
1273             if (not defined $mapping) { # ignore unknown mappings
1274                 $key = undef;
1275                 next;
1276             }
1277         }
1278
1279         debug \%config, 'input', 'running mapping';
1280         my $result = $mapping->($key, $screen, \%config, $input);
1281         $mapping = undef;
1282
1283 RESULT:
1284         if (defined $result->{quit}) {
1285             debug \%config, 'input', 'quitting';
1286             last;
1287         }
1288         if (defined $result->{key}) {
1289             $key = $result->{key}; # lookup another mapping
1290             debug \%config, 'input', "processing new key: '$key'";
1291             next;
1292         }
1293         if (defined $result->{select}) {
1294             debug \%config, 'input', 'selecting match';
1295             my $tmp = $result;
1296             $result = select_match($result->{select},
1297                                 $screen, \%config, $input,
1298                                 $result->{matches});
1299             $result->{handler} = $tmp->{handler};
1300             $result->{extend}  = $tmp->{extend};
1301             goto RESULT; # reprocess special entries in result
1302         }
1303         if (defined $result->{extend}) {
1304             debug \%config, 'input', 'extending match';
1305             $result = extend_match($screen, \%config, $input,
1306                                    $result->{match});
1307             goto RESULT; # reprocess special entries in result
1308         }
1309         if (defined $result->{match}) {
1310             if (not defined $result->{match}->{value}) {
1311                 $result->{match}->{value} = $result->{match}->{string};
1312             }
1313
1314             debug \%config, 'input', 'running handler';
1315
1316             # Choose handler with falling priority.
1317             my @handlers = (
1318                 $config{state}{handler},     # set by user
1319                 $result->{match}->{handler}, # set by match
1320                 $result->{handler},          # set by mapping
1321                 $config{handler}{yank},      # fallback
1322             );
1323             foreach my $handler (@handlers) {
1324                 next unless defined $handler;
1325
1326                 $handler->($screen, \%config, $result->{match});
1327                 last;
1328             }
1329             last;
1330         }
1331
1332         $key = undef; # get next key from user
1333     }
1334 };
1335 if ($@) {
1336     $screen->die("$@");
1337 }
1338
1339 $screen->deinit;
1340
1341 __END__
1342
1343 =head1 EXIT STATUS
1344
1345 =over 4
1346
1347 =item B<0>
1348
1349 Success.
1350
1351 =item B<1>
1352
1353 An error occurred.
1354
1355 =item B<2>
1356
1357 Invalid arguments/options.
1358
1359 =back
1360
1361 =head1 AUTHOR
1362
1363 Simon Ruderich E<lt>simon@ruderich.orgE<gt>
1364
1365 =head1 LICENSE AND COPYRIGHT
1366
1367 Copyright (C) 2013-2016 by Simon Ruderich
1368
1369 This program is free software: you can redistribute it and/or modify
1370 it under the terms of the GNU General Public License as published by
1371 the Free Software Foundation, either version 3 of the License, or
1372 (at your option) any later version.
1373
1374 This program is distributed in the hope that it will be useful,
1375 but WITHOUT ANY WARRANTY; without even the implied warranty of
1376 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1377 GNU General Public License for more details.
1378
1379 You should have received a copy of the GNU General Public License
1380 along with this program.  If not, see E<lt>http://www.gnu.org/licenses/E<gt>.
1381
1382 =head1 SEE ALSO
1383
1384 L<screen(1)>, L<tmux(1)>, L<urlview(1)>
1385
1386 =cut