]> ruderich.org/simon Gitweb - fcscs/fcscs.git/blob - bin/fcscs
fix match drawing if match group doesn't start at the beginning
[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} = \&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} = \&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 => \&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 => \&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 => \&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 my %state = (
971     initial => 1, # used by select_match() for 'initial_mode'
972     handler => undef,
973 );
974
975
976
977 # CONFIGURATION "API"
978
979 =head2 FUNCTIONS
980
981 The following functions are available:
982
983     color_pair($fg, $bg)
984
985 Create a new Curses attribute with the given fore- and background color.
986
987     mapping_mode_path()
988     mapping_mode_url()
989     mapping_mode_search()
990
991     mapping_paste()
992     mapping_yank()
993     mapping_quit()
994
995 Used as mappings, see L</MAPPINGS> above.
996
997     handler_yank()
998     handler_paste()
999     handler_url()
1000
1001 Used as handler to yank, paste selection or open URL in browser. They are
1002 either set by the matching mapping function (C<mapping_paste()>, etc.) or
1003 configured by the current mode.
1004
1005     get_regex_matches()
1006     select_match()
1007     run_command()
1008     run_in_background()
1009
1010 Helper functions when writing custom mappings, see the source for details.
1011
1012 Example:
1013
1014     TODO
1015
1016 =cut
1017
1018 # All variables and functions which are usable by ~/.fcscsrc.
1019 package Fcscs {
1020     our $screen; # "private"
1021     our %config;
1022
1023     sub color_pair { return $screen->color_pair(@_); }
1024
1025     sub mapping_mode_path { return main::mapping_mode_path(@_); }
1026     sub mapping_mode_url { return main::mapping_mode_url(@_); }
1027     sub mapping_mode_search { return main::mapping_mode_search(@_); }
1028
1029     sub mapping_paste { return main::mapping_paste(@_); }
1030     sub mapping_yank { return main::mapping_yank(@_); }
1031     sub mapping_quit { return main::mapping_quit(@_); }
1032
1033     sub handler_yank { return main::handler_yank(@_); }
1034     sub handler_paste { return main::handler_paste(@_); }
1035     sub handler_url { return main::handler_url(@_); }
1036
1037     sub get_regex_matches { return main::get_regex_matches(@_); }
1038     sub select_match { return main::select_match(@_); }
1039
1040     sub run_command { return main::run_command(@_); }
1041     sub run_in_background { return main::run_in_background(@_); }
1042 }
1043 $Fcscs::screen = $screen;
1044
1045
1046
1047 # LOAD USER CONFIG
1048
1049 # Alias %config and %Fcscs::config. %config is less to type.
1050 our %config;
1051 local *config = \%Fcscs::config;
1052
1053 $config{mapping}{mode}   = \%mapping_mode;
1054 $config{mapping}{simple} = \%mapping_simple;
1055 $config{attribute}       = \%attribute;
1056 $config{setting}         = \%setting;
1057 $config{regex}           = \%regex;
1058 $config{state}           = \%state;
1059
1060 package Fcscs {
1061     my @configs = ("$ENV{HOME}/.fcscsrc",
1062                    "$ENV{HOME}/.config/fcscs/fcscsrc");
1063     foreach my $path (@configs) {
1064         my $decoded = $screen->decode($path);
1065
1066         # Load configuration file. Checks have a race condition if the home
1067         # directory is writable by an attacker (but then the user is screwed
1068         # anyway).
1069         next unless -e $path;
1070         if (not -O $path) {
1071             $screen->die("Config '$decoded' not owned by current user!");
1072         }
1073         # Make sure the file is not writable by other users. Doesn't handle
1074         # ACLs and see comment above about race conditions.
1075         my @stat = stat $path or die $!;
1076         my $mode = $stat[2];
1077         if (($mode & Fcntl::S_IWGRP) or ($mode & Fcntl::S_IWOTH)) {
1078             die "Config '$decoded' must not be writable by other users.";
1079         }
1080
1081         my $result = do $path;
1082         if (not $result) {
1083             $screen->die("Failed to parse '$decoded': $@") if $@;
1084             $screen->die("Failed to do '$decoded': $!") unless defined $result;
1085             $screen->die("Failed to run '$decoded'.");
1086         }
1087
1088         last; # success, don't load more files
1089     }
1090 }
1091 $screen->{debug} = $config{setting}{debug};
1092
1093
1094 # MAIN
1095
1096 eval {
1097     # Auto-detect current multiplexer.
1098     if (not defined $config{setting}{multiplexer}) {
1099         if (defined $ENV{STY} and defined $ENV{TMUX}) {
1100             die 'Found both $STY and $TMUX, set $config{setting}{multiplexer}.';
1101         } elsif (defined $ENV{STY}) {
1102             $config{setting}{multiplexer} = 'screen';
1103         } elsif (defined $ENV{TMUX}) {
1104             $config{setting}{multiplexer} = 'tmux';
1105         } else {
1106             die 'No multiplexer found.';
1107         }
1108     }
1109
1110     my $binmode = $encoding;
1111     # GNU screen stores the screen dump for unknown reasons as ISO-8859-1
1112     # instead of the currently active encoding.
1113     if ($config{setting}{multiplexer} eq 'screen') {
1114         $binmode = 'ISO-8859-1';
1115     }
1116
1117     my @input_lines;
1118     open my $fh, '<', $ARGV[0] or die $!;
1119     binmode $fh, ":encoding($binmode)" or die $!;
1120     while (<$fh>) {
1121         chomp;
1122         push @input_lines, $_;
1123     }
1124     close $fh or die $!;
1125
1126     my $input = prepare_input($screen, \@input_lines);
1127
1128     # Display original screen content.
1129     my $y = 0;
1130     foreach (@{$input->{lines}}) {
1131         $screen->draw_simple($y++, 0, undef, $_);
1132     }
1133     $screen->refresh;
1134
1135
1136     my $mapping = $config{setting}{initial_mode};
1137
1138     my $key;
1139     while (1) {
1140         if (not defined $mapping) {
1141             $key = $screen->getch unless defined $key;
1142             debug \%config, 'input', "got key '$key'";
1143
1144             $mapping = $config{mapping}{mode}{$key};
1145             $mapping = $config{mapping}{simple}{$key} unless defined $mapping;
1146             if (not defined $mapping) { # ignore unknown mappings
1147                 $key = undef;
1148                 next;
1149             }
1150         }
1151
1152         debug \%config, 'input', 'running mapping';
1153         my $result = $mapping->($key, $screen, \%config, $input);
1154         $mapping = undef;
1155
1156 RESULT:
1157         if (defined $result->{quit}) {
1158             debug \%config, 'input', 'quitting';
1159             last;
1160         }
1161         if (defined $result->{key}) {
1162             $key = $result->{key}; # lookup another mapping
1163             debug \%config, 'input', "processing new key: '$key'";
1164             next;
1165         }
1166         if (defined $result->{select}) {
1167             debug \%config, 'input', 'selecting match';
1168             my $tmp = $result;
1169             $result = select_match($result->{select},
1170                                 $screen, \%config, $input,
1171                                 $result->{matches});
1172             $result->{handler} = $tmp->{handler};
1173             goto RESULT; # reprocess special entries in result
1174         }
1175         if (defined $result->{match}) {
1176             debug \%config, 'input', 'running handler';
1177             my $handler = $config{state}{handler};                 # set by user
1178             $handler = $result->{handler} unless defined $handler; # set by mapping
1179             $handler = \&handler_yank     unless defined $handler; # fallback
1180             $handler->($screen, \%config, $result->{match});
1181             last;
1182         }
1183
1184         $key = undef; # get next key from user
1185     }
1186 };
1187 if ($@) {
1188     $screen->die("$@");
1189 }
1190
1191 $screen->deinit;
1192
1193 __END__
1194
1195 =head1 EXIT STATUS
1196
1197 =over 4
1198
1199 =item B<0>
1200
1201 Success.
1202
1203 =item B<1>
1204
1205 An error occurred.
1206
1207 =item B<2>
1208
1209 Invalid arguments/options.
1210
1211 =back
1212
1213 =head1 AUTHOR
1214
1215 Simon Ruderich E<lt>simon@ruderich.orgE<gt>
1216
1217 =head1 LICENSE AND COPYRIGHT
1218
1219 Copyright (C) 2013-2016 by Simon Ruderich
1220
1221 This program is free software: you can redistribute it and/or modify
1222 it under the terms of the GNU General Public License as published by
1223 the Free Software Foundation, either version 3 of the License, or
1224 (at your option) any later version.
1225
1226 This program is distributed in the hope that it will be useful,
1227 but WITHOUT ANY WARRANTY; without even the implied warranty of
1228 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
1229 GNU General Public License for more details.
1230
1231 You should have received a copy of the GNU General Public License
1232 along with this program.  If not, see E<lt>http://www.gnu.org/licenses/E<gt>.
1233
1234 =head1 SEE ALSO
1235
1236 L<screen(1)>, L<tmux(1)>, L<urlview(1)>
1237
1238 =cut