507 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			Perl
		
	
	
	
	
	
			
		
		
	
	
			507 lines
		
	
	
	
		
			13 KiB
		
	
	
	
		
			Perl
		
	
	
	
	
	
use strict;
 | 
						|
use warnings;
 | 
						|
use Xchat ();
 | 
						|
use File::Spec ();
 | 
						|
use File::Basename qw(fileparse);
 | 
						|
 | 
						|
# if the last time you addressed someone was greater than this many minutes
 | 
						|
# ago, ignore it
 | 
						|
# this avoids having people you have talked to a long time ago coming up too
 | 
						|
# early in the completion list
 | 
						|
# Setting this to 0 will disable the check which is effectively the same as
 | 
						|
# setting it to infinity
 | 
						|
my $last_use_threshold = 10; # 10 minutes
 | 
						|
 | 
						|
# added to the front of a completion the same way as a suffix, only if
 | 
						|
# the word is at the beginning of the line
 | 
						|
my $prefix = '';
 | 
						|
 | 
						|
# ignore leading non-alphanumeric characters: -[\]^_`{|}
 | 
						|
# Assuming you have the following nicks in a channel:
 | 
						|
# [SomeNick] _SomeNick_ `SomeNick SomeNick SomeOtherNick
 | 
						|
# when $ignore_leading_non_alnum is set to 0
 | 
						|
#     s<tab> will cycle through SomeNick and SomeOtherNick
 | 
						|
# when $ignore_leading_non_alnum is set to 1
 | 
						|
#     s<tab> will cycle through [SomeNick] _SomeNick_ `SomeNick SomeNick
 | 
						|
#     SomeOtherNick
 | 
						|
my $ignore_leading_non_alnum = 0;
 | 
						|
 | 
						|
# enable path completion
 | 
						|
my $path_completion = 1;
 | 
						|
my $base_path = '';
 | 
						|
 | 
						|
Xchat::register(
 | 
						|
	"Tab Completion", "1.0401", "Alternative tab completion behavior"
 | 
						|
);
 | 
						|
Xchat::hook_print( "Key Press", \&complete );
 | 
						|
Xchat::hook_print( "Close Context", \&close_context );
 | 
						|
Xchat::hook_print( "Focus Tab", \&focus_tab );
 | 
						|
Xchat::hook_print( "Part", \&clean_selected );
 | 
						|
Xchat::hook_print( "Part with Reason", \&clean_selected );
 | 
						|
Xchat::hook_command( "", \&track_selected );
 | 
						|
 | 
						|
sub SHIFT() { 1 }
 | 
						|
sub CTRL() { 4 }
 | 
						|
sub ALT() { 8 }
 | 
						|
 | 
						|
sub TAB() { 0xFF09 }
 | 
						|
sub LEFT_TAB() { 0xFE20 }
 | 
						|
 | 
						|
my %completions;
 | 
						|
my %last_visit;
 | 
						|
my %selected;
 | 
						|
my %escape_map = (
 | 
						|
	'[' => qr![\[{]!,
 | 
						|
	'{' => qr![\[{]!,
 | 
						|
	'}' => qr![\]}]!,
 | 
						|
	']' => qr![\]}]!,
 | 
						|
	'\\' => qr![\\\|]!,
 | 
						|
	'|' => qr![\\\|]!,
 | 
						|
	'.' => qr!\.!,
 | 
						|
	'^' => qr!\^!,
 | 
						|
	'$' => qr!\$!,
 | 
						|
	'*' => qr!\*!,
 | 
						|
	'+' => qr!\+!,
 | 
						|
	'?' => qr!\?!,
 | 
						|
	'(' => qr!\(!,
 | 
						|
	')' => qr!\)!,
 | 
						|
	'-' => qr!\-!,
 | 
						|
);
 | 
						|
 | 
						|
my $escapes = join "", keys %escape_map;
 | 
						|
$escapes = qr/[\Q$escapes\E]/;
 | 
						|
 | 
						|
# used to determine if a word is the start of a path
 | 
						|
my $path_pattern = qr{^(?:~|/|[[:alpha:]]:\\)};
 | 
						|
 | 
						|
sub complete {
 | 
						|
	my ($key, $modifiers) = @{$_[0]};
 | 
						|
	# if $_[0][0] contains the value of the key pressed
 | 
						|
	# $_[0][1] contains modifiers
 | 
						|
	# the value for tab is 0xFF09
 | 
						|
	# the value for shift-tab(Left Tab) is 0xFE20
 | 
						|
	# we don't care about other keys
 | 
						|
 | 
						|
	# the key must be a tab and left tab
 | 
						|
	return Xchat::EAT_NONE unless $key == TAB || $key == LEFT_TAB;
 | 
						|
 | 
						|
	# if it is a tab then it must not have any modifiers
 | 
						|
	return Xchat::EAT_NONE if $key == TAB && $modifiers & (CTRL|ALT|SHIFT);
 | 
						|
 | 
						|
	# loop backwards for shift+tab/left tab
 | 
						|
	my $delta = $modifiers & SHIFT ? -1 : 1;
 | 
						|
	my $context = Xchat::get_context;
 | 
						|
	$completions{$context} ||= {};
 | 
						|
	
 | 
						|
	my $completions = $completions{$context};
 | 
						|
	$completions->{pos} ||= -1;
 | 
						|
 | 
						|
	my $suffix = Xchat::get_prefs( "completion_suffix" );
 | 
						|
	$suffix =~ s/^\s+//;
 | 
						|
	
 | 
						|
	my $input = Xchat::get_info( "inputbox" );
 | 
						|
	my $cursor_pos = Xchat::get_info( "state_cursor" );
 | 
						|
	my $left = substr( $input, 0, $cursor_pos );
 | 
						|
	my $right = substr( $input, $cursor_pos );
 | 
						|
	my $length = length $left;
 | 
						|
 | 
						|
	# trim spaces from the end of $left to avoid grabbing the wrong word
 | 
						|
	# this is mainly needed for completion at the very beginning where a space
 | 
						|
	# is added after the completion
 | 
						|
	$left =~ s/\s+$//;
 | 
						|
 | 
						|
	# always add one to the index because
 | 
						|
	# 1) if a space is found we want the position after it
 | 
						|
	# 2) if a space isn't found then we get back -1
 | 
						|
	my $word_start = rindex( $left, " " ) + 1;
 | 
						|
	my $word = substr( $left, $word_start );
 | 
						|
	$left = substr( $left, 0, -length $word );
 | 
						|
 | 
						|
	if( $cursor_pos == $completions->{pos} ) {
 | 
						|
		my $previous_word = $completions->{completed};
 | 
						|
		my $new_left = $input;
 | 
						|
		substr( $new_left, $cursor_pos ) = "";
 | 
						|
 | 
						|
		if( $previous_word and $new_left =~ s/(\Q$previous_word\E)$// ) {
 | 
						|
			$word = $1;
 | 
						|
			$word_start = length( $new_left );
 | 
						|
			$left = $new_left;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	my $command_char = Xchat::get_prefs( "input_command_char" );
 | 
						|
	# ignore commands
 | 
						|
	if( ($word !~ m{^[${command_char}]})
 | 
						|
		or ( $word =~ m{^[${command_char}]} and $word_start != 0 ) ) {
 | 
						|
 | 
						|
		if( $cursor_pos == length $input # end of input box
 | 
						|
			# not a valid nick char
 | 
						|
			&& $input =~ /(?<![\x41-\x5A\x61-\x7A\x30-\x39\x5B-\x60\x7B-\x7D-])$/
 | 
						|
			&& $cursor_pos != $completions->{pos} # not continuing a completion
 | 
						|
			&& $word !~ m{^(?:[&#/~]|[[:alpha:]]:\\)}  # not a channel or path
 | 
						|
		) {
 | 
						|
			# check for path completion
 | 
						|
			unless( $path_completion and $word =~ $path_pattern ) {
 | 
						|
				$word_start = $cursor_pos;
 | 
						|
				$left = $input;
 | 
						|
				$length = length $length;
 | 
						|
				$right = "";
 | 
						|
				$word = "";
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if( $word_start == 0 && $prefix && $word =~ /^\Q$prefix/ ) {
 | 
						|
			$word =~ s/^\Q$prefix//;
 | 
						|
		}
 | 
						|
 | 
						|
		my $completed; # this is going to be the "completed" word
 | 
						|
 | 
						|
		# for parital completions and channel names so a : isn't added
 | 
						|
		#$completions->{skip_suffix} = ($word =~ /^[&#]/) ? 1 : 0;
 | 
						|
		
 | 
						|
		# continuing from a previous completion
 | 
						|
		if(
 | 
						|
			exists $completions->{matches} && @{$completions->{matches}}
 | 
						|
			&& $cursor_pos == $completions->{pos}
 | 
						|
			&& $word =~ /^\Q$completions->{matches}[$completions->{index}]/
 | 
						|
		) {
 | 
						|
			$completions->{index} += $delta;
 | 
						|
 | 
						|
			if( $completions->{index} < 0 ) {
 | 
						|
				$completions->{index} += @{$completions->{matches}};
 | 
						|
			} else {
 | 
						|
				$completions->{index} %= @{$completions->{matches}};
 | 
						|
			}
 | 
						|
 | 
						|
		} else {
 | 
						|
 | 
						|
			if( $word =~ /^[&#]/ ) {
 | 
						|
			# channel name completion
 | 
						|
				$completions->{matches} = [ matching_channels( $word ) ];
 | 
						|
				$completions->{skip_suffix} = 0;
 | 
						|
			} elsif( $path_completion and $word =~ $path_pattern ) {
 | 
						|
			# file name completion
 | 
						|
				$completions->{matches} = [ matching_files( $word ) ];
 | 
						|
				$completions->{skip_suffix} = 1;
 | 
						|
			} else {
 | 
						|
			# nick completion
 | 
						|
				# fix $word so { equals [, ] equals }, \ equals |
 | 
						|
				# and escape regex metacharacters
 | 
						|
				$word =~ s/($escapes)/$escape_map{$1}/g;
 | 
						|
 | 
						|
				$completions->{matches} = [ matching_nicks( $word ) ];
 | 
						|
				$completions->{skip_suffix} = 0;
 | 
						|
			}
 | 
						|
			$completions->{index} = 0;
 | 
						|
 | 
						|
		}
 | 
						|
		$completed = $completions->{matches}[ $completions->{index} ];
 | 
						|
		$completions->{completed} = $completed;
 | 
						|
 | 
						|
		my $completion_amount = Xchat::get_prefs( "completion_amount" );
 | 
						|
		
 | 
						|
		# don't cycle if the number of possible completions is greater than
 | 
						|
		# completion_amount
 | 
						|
		if(
 | 
						|
			@{$completions->{matches}} > $completion_amount
 | 
						|
			&& @{$completions->{matches}} != 1
 | 
						|
		) {
 | 
						|
			# don't print if we tabbed in the beginning and the list of possible
 | 
						|
			# completions includes all nicks in the channel
 | 
						|
			my $context_type = Xchat::context_info->{type};
 | 
						|
			if( $context_type != 2 # not a channel
 | 
						|
				or @{$completions->{matches}} < Xchat::get_list("users")
 | 
						|
			) {
 | 
						|
				Xchat::print( join " ", @{$completions->{matches}}, "\n" );
 | 
						|
			}
 | 
						|
			
 | 
						|
			$completed = lcs( $completions->{matches} );
 | 
						|
			$completions->{skip_suffix} = 1;
 | 
						|
		}
 | 
						|
		
 | 
						|
		if( $completed ) {
 | 
						|
			
 | 
						|
			if( $word_start == 0 && !$completions->{skip_suffix} ) {
 | 
						|
				# at the start of the line append completion suffix
 | 
						|
				Xchat::command( "settext $prefix$completed$suffix$right");
 | 
						|
				$completions->{pos} = length( "$prefix$completed$suffix" );
 | 
						|
			} else {
 | 
						|
				Xchat::command( "settext $left$completed$right" );
 | 
						|
				$completions->{pos} = length( "$left$completed" );
 | 
						|
			}
 | 
						|
			
 | 
						|
			Xchat::command( "setcursor $completions->{pos}" );
 | 
						|
		}
 | 
						|
 | 
						|
=begin
 | 
						|
# debugging stuff
 | 
						|
		local $, = " ";
 | 
						|
		my $input_length = length $input;
 | 
						|
		Xchat::print [
 | 
						|
			qq{input[$input]},
 | 
						|
			qq{input_length[$input_length]},
 | 
						|
			qq{cursor[$cursor_pos]},
 | 
						|
			qq{start[$word_start]},
 | 
						|
			qq{length[$length]},
 | 
						|
			qq{left[$left]},
 | 
						|
			qq{word[$word]}, qq{right[$right]},
 | 
						|
			qq{completed[}. ($completed||""). qq{]},
 | 
						|
			qq{pos[$completions->{pos}]},
 | 
						|
		];
 | 
						|
		use Data::Dumper;
 | 
						|
		local $Data::Dumper::Indent = 0;
 | 
						|
		Xchat::print Dumper $completions->{matches};
 | 
						|
=cut
 | 
						|
 | 
						|
		return Xchat::EAT_ALL;
 | 
						|
	} else {
 | 
						|
		return Xchat::EAT_NONE;
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
 | 
						|
# all channels starting with $word
 | 
						|
sub matching_channels {
 | 
						|
	my $word = shift;
 | 
						|
 | 
						|
	# for use in compare_channels();
 | 
						|
	our $current_chan;
 | 
						|
	local $current_chan = Xchat::get_info( "channel" );
 | 
						|
 | 
						|
	my $conn_id = Xchat::get_info( "id" );
 | 
						|
	$word =~ s/^[&#]+//;
 | 
						|
 | 
						|
	return
 | 
						|
		map {	$_->[1]->{channel} }
 | 
						|
		sort compare_channels map {
 | 
						|
			my $chan = $_->{channel};
 | 
						|
			$chan =~ s/^[#&]+//;
 | 
						|
 | 
						|
			# comparisons will be done based only on the name
 | 
						|
			# matching name, same connection, only channels
 | 
						|
			$chan =~ /^$word/i && $_->{id} == $conn_id ?
 | 
						|
			[ $chan, $_ ] :
 | 
						|
			()
 | 
						|
		} channels();
 | 
						|
}
 | 
						|
 | 
						|
sub channels {
 | 
						|
	return grep { $_->{type} == 2 } Xchat::get_list( "channels" );
 | 
						|
}
 | 
						|
 | 
						|
sub compare_channels {
 | 
						|
	# package variable, value set in matching_channels()
 | 
						|
	our $current_chan;
 | 
						|
 | 
						|
	# turn off warnings generated from channels that have not yet been visited
 | 
						|
	# since the script was loaded
 | 
						|
	no warnings "uninitialized";
 | 
						|
 | 
						|
	# the current channel is always first, then ordered by most recently visited
 | 
						|
	return
 | 
						|
		$a->[1]{channel} eq $current_chan ? -1 :
 | 
						|
		$b->[1]{channel} eq $current_chan ? 1 :
 | 
						|
		$last_visit{ $b->[1]{context} } <=> $last_visit{ $a->[1]{context} }
 | 
						|
		|| $a->[1]{channel} cmp $b->[1]{channel};
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
sub matching_nicks {
 | 
						|
	my $word_re = shift;
 | 
						|
 | 
						|
	# for use in compare_nicks()
 | 
						|
	our ($my_nick, $selections, $now);
 | 
						|
	local $my_nick = Xchat::get_info( "nick" );
 | 
						|
	local $selections = $selected{ Xchat::get_context() };
 | 
						|
	local $now = time;
 | 
						|
 | 
						|
	my $pattern = $ignore_leading_non_alnum ?
 | 
						|
		qr/^[\-\[\]^_`{|}\\]*$word_re/i : qr/^$word_re/i;
 | 
						|
	return
 | 
						|
		map { $_->{nick} }
 | 
						|
		sort compare_nicks grep {
 | 
						|
			$_->{nick} =~ $pattern;
 | 
						|
		} Xchat::get_list( "users" )
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
sub max {
 | 
						|
	return unless @_;
 | 
						|
	my $max = shift;
 | 
						|
	for(@_) {
 | 
						|
		$max = $_ if $_ > $max;
 | 
						|
	}
 | 
						|
	return $max;
 | 
						|
}
 | 
						|
 | 
						|
sub compare_times {
 | 
						|
	# package variables set in matching_nicks()
 | 
						|
	our $selections;
 | 
						|
	our $now;
 | 
						|
	
 | 
						|
	for my $nick ( $a->{nick}, $b->{nick} ) {
 | 
						|
		# turn off the warnings that get generated from users who have yet
 | 
						|
		# to speak since the script was loaded
 | 
						|
		no warnings "uninitialized";
 | 
						|
 | 
						|
		if( $last_use_threshold
 | 
						|
			&& (( $now - $selections->{$nick}) > ($last_use_threshold * 60)) ) {
 | 
						|
			delete $selections->{ $nick }
 | 
						|
		}
 | 
						|
	}
 | 
						|
	my $a_time = $selections->{ $a->{nick} } || 0 ;
 | 
						|
	my $b_time = $selections->{ $b->{nick} } || 0 ;
 | 
						|
	
 | 
						|
	if( $a_time || $b_time ) {
 | 
						|
		return $b_time <=> $a_time;
 | 
						|
	} elsif( !$a_time && !$b_time ) {
 | 
						|
		return $b->{lasttalk} <=> $a->{lasttalk};
 | 
						|
	}
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
sub compare_nicks {
 | 
						|
	# more package variables, value set in matching_nicks()
 | 
						|
	our $my_nick;
 | 
						|
 | 
						|
	# our own nick is always last, then ordered by the people we spoke to most
 | 
						|
	# recently and the people who were speaking most recently
 | 
						|
	return 
 | 
						|
		$a->{nick} eq $my_nick ? 1 :
 | 
						|
		$b->{nick} eq $my_nick ? -1 :
 | 
						|
		compare_times()
 | 
						|
		|| Xchat::nickcmp( $a->{nick}, $b->{nick} );
 | 
						|
 | 
						|
#		$selections->{ $b->{nick} } <=> $selections->{ $a->{nick} }
 | 
						|
#		||	$b->{lasttalk} <=> $a->{lasttalk}
 | 
						|
 | 
						|
}
 | 
						|
 | 
						|
sub matching_files {
 | 
						|
	my $word = shift;
 | 
						|
 | 
						|
	my ($file, $input_dir) = fileparse( $word );
 | 
						|
 | 
						|
	my $dir = expand_tilde( $input_dir );
 | 
						|
 | 
						|
	if( opendir my $dir_handle, $dir ) {
 | 
						|
		my @files;
 | 
						|
 | 
						|
		if( $file ) {
 | 
						|
			@files = grep {
 | 
						|
				#Xchat::print( $_ );
 | 
						|
				/^\Q$file/ } readdir $dir_handle;
 | 
						|
		} else {
 | 
						|
			@files = readdir $dir_handle;
 | 
						|
		}
 | 
						|
 | 
						|
		return map {
 | 
						|
			File::Spec->catfile( $input_dir, $_ );
 | 
						|
		} sort
 | 
						|
		grep { !/^[.]{1,2}$/ } @files;
 | 
						|
	} else {
 | 
						|
		return ();
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
# Remove completion related data for tabs that are closed
 | 
						|
sub close_context {
 | 
						|
	my $context = Xchat::get_context;
 | 
						|
	delete $completions{$context};
 | 
						|
	delete $last_visit{$context};
 | 
						|
	return Xchat::EAT_NONE;
 | 
						|
}
 | 
						|
 | 
						|
# track visit times
 | 
						|
sub focus_tab {
 | 
						|
	$last_visit{Xchat::get_context()} = time();
 | 
						|
	return Xchat::EAT_NONE;
 | 
						|
}
 | 
						|
 | 
						|
# keep track of the last time a message was addressed to someone
 | 
						|
# a message is considered addressed to someone if their nick is used followed
 | 
						|
# by the completion suffix
 | 
						|
sub track_selected {
 | 
						|
	my $input = $_[1][0];
 | 
						|
	return Xchat::EAT_NONE unless defined $input;
 | 
						|
 | 
						|
	my $suffix = Xchat::get_prefs( "completion_suffix" );
 | 
						|
	for( grep defined, $input =~ /^(.+)\Q$suffix/, $_[0][0] ) {
 | 
						|
		if( in_channel( $_ ) ) {
 | 
						|
			$selected{Xchat::get_context()}{$_} = time();
 | 
						|
			last;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return Xchat::EAT_NONE;
 | 
						|
}
 | 
						|
 | 
						|
# if a user is in the current channel
 | 
						|
# user_info() can also be used instead of the loop
 | 
						|
sub in_channel {
 | 
						|
	my $target = shift;
 | 
						|
	for my $nick ( nicks() ) {
 | 
						|
		if( $nick eq $target ) {
 | 
						|
			return 1;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return 0;
 | 
						|
}
 | 
						|
 | 
						|
# list of nicks in the current channel
 | 
						|
sub nicks {
 | 
						|
	return map { $_->{nick} } Xchat::get_list( "users" );
 | 
						|
}
 | 
						|
 | 
						|
# remove people from the selected list when they leave the channel
 | 
						|
sub clean_selected {
 | 
						|
	delete $selected{ Xchat::get_context() }{$_[0][0]};
 | 
						|
	return Xchat::EAT_NONE;
 | 
						|
}
 | 
						|
 | 
						|
# Longest common substring
 | 
						|
# Used for partial completion when using non-cycling completion
 | 
						|
sub lcs {
 | 
						|
	my @nicks = @{+shift};
 | 
						|
	return "" if @nicks == 0;
 | 
						|
	return $nicks[0] if @nicks == 1;
 | 
						|
 | 
						|
	my $substring = shift @nicks;
 | 
						|
 | 
						|
	while(@nicks) {
 | 
						|
		$substring = common_string( $substring, shift @nicks );
 | 
						|
	}
 | 
						|
	
 | 
						|
	return $substring;
 | 
						|
}
 | 
						|
 | 
						|
sub common_string {
 | 
						|
	my ($nick1, $nick2) = @_;
 | 
						|
	my $index = 0;
 | 
						|
 | 
						|
	$index++ while(
 | 
						|
		($index < length $nick1) && ($index < length $nick2) &&
 | 
						|
			lc(substr( $nick1, $index, 1 )) eq lc(substr( $nick2, $index, 1 ))
 | 
						|
	);
 | 
						|
	
 | 
						|
	
 | 
						|
	return substr( $nick1, 0, $index );
 | 
						|
}
 | 
						|
 | 
						|
sub expand_tilde {
 | 
						|
	my $file = shift;
 | 
						|
 | 
						|
	$file =~ s/^~/home_dir()/e;
 | 
						|
	return $file;
 | 
						|
}
 | 
						|
 | 
						|
sub home_dir {
 | 
						|
	return $base_path if $base_path;
 | 
						|
 | 
						|
	if ( $^O eq "MSWin32" ) {
 | 
						|
		return $ENV{USERPROFILE};
 | 
						|
	} else {
 | 
						|
		return ((getpwuid($>))[7] ||  $ENV{HOME} || $ENV{LOGDIR});
 | 
						|
	}
 | 
						|
}
 |