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