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