# Search and select windows similar to ido-mode for emacs # # INSTALL: # # This script requires that you have first installed and loaded 'uberprompt.pl' # Uberprompt can be downloaded from: # # http://github.com/shabble/irssi-scripts/raw/master/prompt_info/uberprompt.pl # # and follow the instructions at the top of that file for installation. # # SETUP: # # * Setup: /bind ^G /ido_switch_start # # * Then type ctrl-G and type what you're searching for # # USAGE: # # C-g (or whatever you've set the above bind to), enters window switching mode. # # NB: When entering window switching mode, the contents of your input line will # be saved and cleared, to avoid visual clutter whilst using the switching # interface. It will be restored once you exit the mode using either C-g, Esc, # or RET. # The following key-bindings are available only once the mode has been # activated: # # * C-g - cancel out of the mode without changing windows. # * Esc - cancel out, as above. # * C-s - rotate the list of window candidates forward by 1 # * C-r - rotate the list of window candidates backward by 1 # * C-e - Toggle 'Active windows only' filter # * C-f - Switch between 'Flex' and 'Exact' matching. # * C-d - Select a network or server to filter candidates by # * C-u - Clear the current search string # * C-q - Cycle between showing only queries, channels, or all. # * C-SPC - Filter candidates by current search string, and then reset # the search string # * RET - Select the current head of the candidate list (the green one) # * SPC - Select the current head of the list, without exiting the # switching mode. The head is then moved one place to the right, # allowing one to cycle through channels by repeatedly pressing space. # * TAB - [currently in development] displays all possible completions # at the bottom of the current window. # * All other keys (a-z, A-Z, etc) - Add that character to the current search # string. # # USAGE NOTES: # # * Using C-e (show actives), followed by repeatedly pressing space will cycle # through all your currently active windows. # # * If you enter a search string fragment, and realise that more than one candidate # is still presented, rather than delete the whole string and modify it, you can # use C-SPC to 'lock' the current matching candidates, but allow you to search # through those matches alone. # # Based in part on window_switcher.pl script Copyright 2007 Wouter Coekaerts # # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # BUGS: # # * Sometimes selecting a channel with the same name on a different # network will take you to the wrong channel. # # # use strict; use Irssi; use Irssi::TextUI; use Data::Dumper; use vars qw($VERSION %IRSSI); $VERSION = '2.0'; %IRSSI = ( authors => 'Tom Feist, Wouter Coekaerts', contact => 'shabble+irssi@metavore.org, shabble@#irssi/freenode', name => 'ido_switcher', description => 'Select window[-items] using an ido-mode like search interface', license => 'GPLv2 or later', url => 'http://github.com/shabble/irssi-scripts/tree/master/ido-mode/', changed => '24/7/2010' ); # TODO: # DONE C-g - cancel # DONE C-spc - narrow # DONE flex matching (on by default, but optional) # TODO server/network narrowing # DONE colourised output (via uberprompt) # DONE C-r / C-s rotate matches # DONE toggle queries/channels # DONE remove inputline content, restore it afterwards. # TODO tab - display all possibilities in window (clean up afterwards) # how exactly will this work? # DONE sort by recent activity/recently used windows (separate commands?) # TODO need to be able to switch ordering of active ones (numerical, or most recently # active, priority to PMs/hilights, etc?) # DONE should space auto-move forward to next window for easy stepping through # sequential/active windows? my $input_copy = ''; my $input_pos_copy = 0; my $ido_switch_active = 0; # for intercepting keystrokes my @window_cache = (); my @search_matches = (); my $match_index = 0; my $search_str = ''; my $active_only = 0; my $mode_type = 'ALL'; my @mode_cache; my $showing_help = 0; my $need_clear = 0; my $sort_ordering = "start-asc"; my $sort_active_first = 0; # /set configurable settings my $ido_show_count; my $ido_use_flex; my $DEBUG_ENABLED = 0; sub DEBUG () { $DEBUG_ENABLED } sub MODE_WIN () { 0 } # windows sub MODE_NET () { 1 } # chatnets #sub MODE_C () { 2 } # channels #sub MODE_S () { 3 } # select server #sub MODE_W () { 4 } # select window my $MODE = MODE_WIN; # check we have uberprompt loaded. sub _print { my $win = Irssi::active_win; my $str = join('', @_); $need_clear = 1; $win->print($str, Irssi::MSGLEVEL_NEVER); } sub _debug_print { return unless DEBUG; my $win = Irssi::active_win; my $str = join('', @_); $win->print($str, Irssi::MSGLEVEL_CLIENTCRAP); } sub _print_clear { return unless $need_clear; my $win = Irssi::active_win(); $win->command('/scrollback levelclear -level NEVER'); } sub display_help { my @message = ('%_IDO Window Switching Help:%_', '', '%_Ctrl-g%_ - cancel out of the mode without changing windows.', '%_Esc%_ - cancel out, as above.', '%_Ctrl-s%_ - rotate the list of window candidates forward by 1', '%_Ctrl-r%_ - rotate the list of window candidates backward by 1', '%_Ctrl-e%_ - Toggle \'Active windows only\' filter', '%_Ctrl-f%_ - Switch between \'Flex\' and \'Exact\' matching.', '%_Ctrl-d%_ - Select a network or server to filter candidates by', '%_Ctrl-u%_ - Clear the current search string', '%_Ctrl-q%_ - Cycle between showing only queries, channels, or all.', '%_Ctrl-SPC%_ - Filter candidates by current search string, and then ', ' reset the search string', '%_RET%_ - Select the current head of the candidate list (the %_green%n one)', '%_SPC%_ - Select the current head of the list, without exiting switching', ' mode. The head is then moved one place to the right,', ' allowing one to cycle through channels by repeatedly pressing space.', '%_TAB%_ - [%_currently non-functional%_] displays all possible completions', ' at the bottom of the current window.', '', ' %_All other keys (a-z, A-Z, etc) - Add that character to the', ' %_current search string.', '', '%_Press Any Key to return%_', ); _print($_) for @message; $showing_help = 1; } sub print_all_matches { my $msg = join(", ", map { $_->{name} } @search_matches); my $message_header = "Windows:"; my $win = Irssi::active_win(); my $win_width = $win->{width} || 80; # TODO: needs to prefix ambig things with chatnet, or maybe order in groups # by chatnet with newlines. # Also, colourise the channel list. my $col_width; for (@search_matches) { my $len = length($_->{name}); $col_width = $len if $len > $col_width; } my $cols = int($win_width / $col_width); my @lines; my $i = 0; my @line; for my $item (@search_matches) { my $name = $item->{name}; push @line, sprintf('%.*s', $col_width, $name); if ($i == $cols) { push @lines, join ' ', @line; @line = (); $i = 0; } } # flush rest out. push @lines, join ' ', @line; _print($message_header); _print($_) for (@lines); #_print("Longtest name: $longest_name"); } sub script_is_loaded { my $name = shift; _debug_print "Checking if $name is loaded"; no strict 'refs'; my $retval = defined %{ "Irssi::Script::${name}::" }; use strict 'refs'; return $retval; } unless (script_is_loaded('uberprompt')) { _print "This script requires '\%_uberprompt.pl\%_' in order to work. " . "Attempting to load it now..."; Irssi::signal_add('script error', 'load_uberprompt_failed'); Irssi::command("script load uberprompt.pl"); unless(script_is_loaded('uberprompt')) { load_uberprompt_failed("File does not exist"); } ido_switch_init(); } sub load_uberprompt_failed { Irssi::signal_remove('script error', 'load_uberprompt_failed'); _print "Script could not be loaded. Script cannot continue. " . "Check you have uberprompt.pl installed in your path and " . "try again."; die "Script Load Failed: " . join(" ", @_); } sub ido_switch_init { Irssi::settings_add_bool('ido_switch', 'ido_switch_debug', 0); Irssi::settings_add_bool('ido_switch', 'ido_use_flex', 1); Irssi::settings_add_bool('ido_switch', 'ido_show_active_first', 1); Irssi::settings_add_int ('ido_switch', 'ido_show_count', 5); Irssi::command_bind('ido_switch_start', \&ido_switch_start); Irssi::signal_add ('setup changed' => \&setup_changed); Irssi::signal_add_first('gui key pressed' => \&handle_keypress); setup_changed(); } sub setup_changed { $DEBUG_ENABLED = Irssi::settings_get_bool('ido_switch_debug'); $ido_show_count = Irssi::settings_get_int ('ido_show_count'); $ido_use_flex = Irssi::settings_get_bool('ido_use_flex'); $sort_active_first = Irssi::settings_get_bool('ido_show_active_first'); } sub ido_switch_start { # store copy of input line to restore later. $input_copy = Irssi::parse_special('$L'); $input_pos_copy = Irssi::gui_input_get_pos(); Irssi::gui_input_set(''); # set startup flags $ido_switch_active = 1; $search_str = ''; $match_index = 0; $mode_type = 'ALL'; # refresh in case we toggled it last time. $ido_use_flex = Irssi::settings_get_bool('ido_use_flex'); $active_only = 0; _debug_print "Win cache: " . join(", ", map { $_->{name} } @window_cache); _update_cache(); update_matches(); update_window_select_prompt(); } sub _update_cache { @window_cache = get_all_windows(); } sub _build_win_obj { my ($win, $win_item) = @_; my @base = ( b_pos => -1, e_pos => -1, hilight_field => 'name', active => $win->{data_level} > 0, num => $win->{refnum}, server => $win->{active_server}, ); if (defined($win_item)) { return ( @base, name => $win_item->{visible_name}, type => $win_item->{type}, itemname => $win_item->{name}, active => $win_item->{data_level} > 0, ) } else { return ( @base, name => $win->{name}, type => 'WIN', ); } } sub get_all_windows { my @ret; foreach my $win (Irssi::windows()) { my @items = $win->items(); if ($win->{name} ne '') { _debug_print "Adding window: " . $win->{name}; push @ret, { _build_win_obj($win, undef) }; } if (scalar @items) { foreach my $item (@items) { _debug_print "Adding windowitem: " . $item->{visible_name}; push @ret, { _build_win_obj($win, $item) }; } } else { if (not grep { $_->{num} == $win->{refnum} } @ret) { my $item = { _build_win_obj($win, undef) }; $item->{name} = "Unknown"; push @ret, $item; } #_debug_print "Error occurred reading info from window: $win"; #_debug_print Dumper($win); } } @ret = _sort_windows(\@ret); return @ret; } sub _sort_windows { my $list_ref = shift; my @ret = @$list_ref; @ret = sort { $a->{num} <=> $b->{num} } @ret; if ($sort_active_first) { my @active = grep { $_->{active} } @ret; my @inactive = grep { not $_->{active} } @ret; return (@active, @inactive); } else { return @ret; } } sub ido_switch_select { my ($selected, $tag) = @_; _debug_print sprintf("Selecting window: %s (%d)", $selected->{name}, $selected->{num}); Irssi::command("WINDOW GOTO" . $selected->{num}); if ($selected->{type} ne 'WIN') { _debug_print "Selecting window item: " . $selected->{itemname}; Irssi::command("WINDOW ITEM GOTO " . $selected->{itemname}); } update_matches(); } sub ido_switch_exit { $ido_switch_active = 0; _print_clear(); Irssi::gui_input_set($input_copy); Irssi::gui_input_set_pos($input_pos_copy); Irssi::signal_emit('change prompt', '', 'UP_INNER'); } sub _order_matches { return @_[$match_index .. $#_, 0 .. $match_index - 1] } sub update_window_select_prompt { # take the top $ido_show_count entries and display them. my $match_count = scalar @search_matches; my $show_count = $ido_show_count; my $match_string = '[No match'; $show_count = $match_count if $match_count < $show_count; if ($show_count > 0) { # otherwise, default message above. _debug_print "Showing: $show_count matches"; my @ordered_matches = _order_matches(@search_matches); my @display = @ordered_matches[0..$show_count - 1]; # determine which items are non-unique, if any. my %uniq; foreach my $res (@display) { my $name = $res->{name}; if (exists $uniq{$name}) { push @{$uniq{$name}}, $res; } else { $uniq{$name} = []; push @{$uniq{$name}}, $res; } } # and set a flag to ensure they have their network tag applied # to them when drawn. foreach my $name (keys %uniq) { my @values = @{$uniq{$name}}; if (@values > 1) { $_->{display_net} = 1 for @values; } } # show the first entry in green my $first = shift @display; my $formatted_first = _format_display_entry($first, '%g'); unshift @display, $formatted_first; # and array-slice-map the rest to be red. # or yellow, if they have unviewed activity @display[1..$#display] = map { _format_display_entry($_, $_->{active}?'%y':'%r') } @display[1..$#display]; # join em all up $match_string = join ', ', @display; } my @indicators; # indicator if flex mode is being used (C-f to toggle) push @indicators, $ido_use_flex ? 'Flex' : 'Exact'; push @indicators, 'Active' if $active_only; push @indicators, ucfirst(lc($mode_type)); my $flex = sprintf(' %%k[%%n%s%%k]%%n ', join ',', @indicators); my $search = ''; $search = (sprintf '`%s\': ', $search_str) if length $search_str; Irssi::signal_emit('change prompt', $flex . $search . $match_string, 'UP_INNER'); } sub _format_display_entry { my ($obj, $colour) = @_; my $field = $obj->{hilight_field}; my $hilighted = { name => $obj->{name}, num => $obj->{num} }; my $show_tag = $obj->{display_net} || 0; if ($obj->{b_pos} >= 0 && $obj->{e_pos} > $obj->{b_pos}) { substr($hilighted->{$field}, $obj->{e_pos}, 0) = '%_'; substr($hilighted->{$field}, $obj->{b_pos}, 0) = '%_'; _debug_print "Showing $field as: " . $hilighted->{$field} } return sprintf('%s%s:%s%s%%n', $colour, $hilighted->{num}, $show_tag ? _format_display_tag($obj) : '', $hilighted->{name}); } sub _format_display_tag { my $obj = shift; if (defined $obj->{server}) { my $server = $obj->{server}; my $tag = $server->{tag}; return $tag . '/' if length $tag; } return ''; } sub _check_active { my ($obj) = @_; return 1 unless $active_only; return $obj->{active}; } sub update_matches { _update_cache() unless $search_str; if ($mode_type ne 'ALL') { @mode_cache = @window_cache; @window_cache = grep { print "Type: " . $_->{type}; $_->{type} eq $mode_type } @window_cache; } else { @window_cache = @mode_cache if @mode_cache; } if ($search_str =~ m/^\d+$/) { @search_matches = grep { _check_active($_) and regex_match($_, 'num') } @window_cache; } elsif ($ido_use_flex) { @search_matches = grep { _check_active($_) and flex_match($_) >= 0 } @window_cache; } else { @search_matches = grep { _check_active($_) and regex_match($_, 'name') } @window_cache; } } sub regex_match { my ($obj, $field) = @_; if ($obj->{$field} =~ m/^(.*?)\Q$search_str\E(.*?)$/i) { $obj->{hilight_field} = $field; $obj->{b_pos} = length $1; $obj->{e_pos} = $obj->{b_pos} + length($search_str); return 1; } return 0; } sub flex_match { my ($obj) = @_; my $pattern = $search_str; my $source = $obj->{name}; _debug_print "Flex match: $pattern / $source"; # default to matching everything if we don't have a pattern to compare # against. return 0 unless $pattern; my @chars = split '', lc($pattern); my $ret = -1; my $first = 0; my $lc_source = lc($source); $obj->{hilight_field} = 'name'; foreach my $char (@chars) { my $pos = index($lc_source, $char, $ret); if ($pos > -1) { # store the beginning of the match $obj->{b_pos} = $pos unless $first; $first = 1; _debug_print("matched: $char at $pos in $source"); $ret = $pos + 1; } else { $obj->{b_pos} = $obj->{e_pos} = -1; _debug_print "Flex returning: -1"; return -1; } } _debug_print "Flex returning: $ret"; #store the end of the match. $obj->{e_pos} = $ret; return $ret; } sub prev_match { $match_index++; if ($match_index > $#search_matches) { $match_index = 0; } _debug_print "index now: $match_index"; } sub next_match { $match_index--; if ($match_index < 0) { $match_index = $#search_matches; } _debug_print "index now: $match_index"; } sub get_window_match { return $search_matches[$match_index]; } sub handle_keypress { my ($key) = @_; return unless $ido_switch_active; if ($showing_help) { _print_clear(); $showing_help = 0; Irssi::signal_stop(); } if ($key == 0) { # C-SPC? _debug_print "\%_Ctrl-space\%_"; $search_str = ''; @window_cache = @search_matches; update_window_select_prompt(); Irssi::signal_stop(); return; } if ($key == 3) { # C-c _print_clear(); Irssi::signal_stop(); return; } if ($key == 4) { # C-d update_network_select_prompt(); Irssi::signal_stop(); return; } if ($key == 5) { # C-e $active_only = not $active_only; Irssi::signal_stop(); update_matches(); update_window_select_prompt(); return; } if ($key == 6) { # C-f $ido_use_flex = not $ido_use_flex; _update_cache(); update_matches(); update_window_select_prompt(); Irssi::signal_stop(); return; } if ($key == 9) { # TAB _debug_print "Tab complete"; print_all_matches(); Irssi::signal_stop(); } if ($key == 10) { # enter _debug_print "selecting history and quitting"; my $selected_win = get_window_match(); ido_switch_select($selected_win); ido_switch_exit(); Irssi::signal_stop(); return; } if ($key == 11) { # Ctrl-K my $sel = get_window_match(); _debug_print("deleting entry: " . $sel->{num}); Irssi::command("window close " . $sel->{num}); _update_cache(); update_matches(); update_window_select_prompt(); Irssi::signal_stop(); } if ($key == 18) { # Ctrl-R _debug_print "skipping to prev match"; #update_matches(); next_match(); update_window_select_prompt(); Irssi::signal_stop(); # prevent the bind from being re-triggered. return; } if ($key == 17) { # Ctrl-q if ($mode_type eq 'CHANNEL') { $mode_type = 'QUERY'; } elsif ($mode_type eq 'QUERY') { $mode_type = 'ALL'; } else { # ALL $mode_type = 'CHANNEL'; } update_matches(); update_window_select_prompt(); Irssi::signal_stop(); } if ($key == 19) { # Ctrl-s _debug_print "skipping to next match"; prev_match(); #update_matches(); update_window_select_prompt(); Irssi::signal_stop(); return; } if ($key == 7) { # Ctrl-g _debug_print "aborting search"; ido_switch_exit(); Irssi::signal_stop(); return; } if ($key == 8) { # Ctrl-h display_help(); Irssi::signal_stop(); return; } if ($key == 21) { # Ctrl-u $search_str = ''; update_matches(); update_window_select_prompt(); Irssi::signal_stop(); return; } if ($key == 127) { # DEL if (length $search_str) { $search_str = substr($search_str, 0, -1); _debug_print "Deleting char, now: $search_str"; } update_matches(); update_window_select_prompt(); Irssi::signal_stop(); return; } # TODO: handle esc- sequences and arrow-keys? if ($key == 27) { # Esc ido_switch_exit(); return; } if ($key == 32) { # space my $selected_win = get_window_match(); ido_switch_select($selected_win); prev_match(); update_window_select_prompt(); Irssi::signal_stop(); return; } if ($key > 32) { # printable $search_str .= chr($key); update_matches(); update_window_select_prompt(); Irssi::signal_stop(); return; } # ignore all other keys. Irssi::signal_stop(); } ido_switch_init(); sub update_network_select_prompt { my @servers = map { { name => $_->{tag}, type => 'SERVER', active => 0, e_pos => -1, b_pos => -1, hilight_field => 'name', } } Irssi::servers(); my $match_count = scalar @servers; my $show_count = $ido_show_count; my $match_string = '(no matches) '; $show_count = $match_count if $match_count < $show_count; if ($show_count > 0) { _debug_print "Showing: $show_count matches"; my @ordered_matches = _order_matches(@servers); my @display = @ordered_matches[0..$show_count - 1]; # show the first entry in green unshift(@display, _format_display_entry(shift(@display), '%g')); # and array-slice-map the rest to be red (or yellow for active) @display[1..$#display] = map { _format_display_entry($_, $_->{active}?'%y':'%r') } @display[1..$#display]; # join em all up $match_string = join ', ', @display; } my @indicators; # indicator if flex mode is being used (C-f to toggle) push @indicators, $ido_use_flex ? 'Flex' : 'Exact'; push @indicators, 'Active' if $active_only; my $flex = sprintf(' %%k[%%n%s%%k]%%n ', join ',', @indicators); my $search = ''; $search = (sprintf '`%s\': ', $search_str) if length $search_str; Irssi::signal_emit('change prompt', $flex . $search . $match_string, 'UP_INNER'); }