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