# chankeys.pl — Irssi script for associating key shortcuts with channels # # © 2021–22 martin f. krafft # Released under the MIT licence. # ### Usage: # # /script load chankeys # # This plugin serves to simplify the assignment of keyboard shortcuts that # take you to channels or queries (so-called "window items"). # # Let's assume you're in the #irssi channel, then you could issue the command # # /chankeys add meta-s-meta-i # # and thenceforth, hitting that key combination will take you to the channel. # It's smart enough to check whether a mapping is already in use by chankey, # or whether a key combination won't work, for instance because meta-s was # already assigned elsewhere in the above. # # You can also explicitly specify the name (and chatnet) if you'd like to # set up a mapping for another item: # # /chankeys add F12 &bitlbee # # Key bindings are removed when you leave a channel or a query is closed, and # reinstated when the channel or query is reinstated. They are saved to # ~/.irssi/chankeys on /save, and loaded from there on startup and /reload. # ### To-do: # # * Mappings for {01..99} and associated hook to renumber windows with named # mappings # * Handle queries better, i.e. they should be created if not found, probably # just use /query instead of /window goto # * When adding a keymap from /chankey add, if the keymap is already assigned # to another channel, we need to handle this better # * check_for_existing_bind really hurts and causes a bit of lag in Irssi that # it doesn't recover from for a few seconds after load. Better to read /bind # output once into a hash and use that. # use strict; use warnings; use Irssi; use version; our %IRSSI = ( authors => 'martin f. krafft', contact => 'madduck@madduck.net', name => 'chankeys', description => 'manage channel keyboard shortcuts', license => 'MIT', version => '0.4.1', changed => '2022-11-18' ); our $VERSION = $IRSSI{version}; my $_VERSION = version->parse($VERSION); ### DEFAULTS AND SETTINGS ###################################################### my $map_file = Irssi::get_irssi_dir()."/chankeys"; my $go_command = 'window goto $C'; my $autosave = 1; my $overwrite_binds = 0; my $clear_composites = 0; my $debug = 0; Irssi::settings_add_str('chankeys', 'chankeys_go_command', $go_command); Irssi::settings_add_bool('chankeys', 'chankeys_autosave', $autosave); Irssi::settings_add_bool('chankeys', 'chankeys_overwrite_binds', $overwrite_binds); Irssi::settings_add_bool('chankeys', 'chankeys_clear_composites', $clear_composites); Irssi::settings_add_bool('chankeys', 'chankeys_debug', $debug); sub sig_setup_changed { $debug = Irssi::settings_get_bool('chankeys_debug'); $clear_composites = Irssi::settings_get_bool('chankeys_clear_composites'); $overwrite_binds = Irssi::settings_get_bool('chankeys_overwrite_binds'); $autosave = Irssi::settings_get_bool('chankeys_autosave'); $go_command = Irssi::settings_get_str('chankeys_go_command'); } Irssi::signal_add('setup changed', \&sig_setup_changed); Irssi::signal_add('setup reread', \&sig_setup_changed); sig_setup_changed(); my $changed_since_last_save = 0; my %itemmap; my %leadkeys; ### HELPERS #################################################################### sub say { my ($msg, $level, $inwin) = @_; $level = $level // MSGLEVEL_CLIENTCRAP; if ($inwin) { Irssi::active_win->print("chankeys: $msg", $level); } else { Irssi::print("chankeys: $msg", $level); } } sub debug { return unless $debug; my ($msg, $inwin) = @_; $msg = $msg // ""; say("DEBUG: ".$msg, MSGLEVEL_CRAP + MSGLEVEL_NO_ACT, $inwin); } sub info { my ($msg, $inwin) = @_; say($msg, MSGLEVEL_CLIENTCRAP, $inwin); } use Data::Dumper; sub dumper { debug(scalar Dumper(@_), 1); } sub warning { my ($msg, $inwin) = @_; $msg = $msg // ""; say("WARNING: ".$msg, MSGLEVEL_CLIENTERROR, $inwin); } sub error { my ($msg, $inwin) = @_; $msg = $msg // ""; say("ERROR: ".$msg, MSGLEVEL_CLIENTERROR, $inwin); } sub channet_pair_to_string { my ($name, $chatnet) = @_; my $ret = $chatnet ? "$chatnet/" : ''; return $ret . $name; } sub string_to_channet_pair { my ($str) = @_; return reverse(split(/\//, $str)); } sub get_keymap_for_channet_pair { my ($name, $chatnet) = @_; foreach my $cn ($chatnet, undef) { # if not found with $chatnet, fallback to no chatnet my $item = channet_pair_to_string($name, $cn); my $keys = $itemmap{$item}; return ($keys, $name, $cn) if $keys; } return (); } sub get_go_command { my ($name, $chatnet) = @_; my $cmd = $go_command; $cmd =~ s/\$C/$name/; $cmd =~ s/\$chatnet/$chatnet/; $cmd =~ s/\s+$//; return $cmd; } my $keybind_to_check; my $existing_binding; sub check_existing_binds { my ($rec, undef, $text) = @_; if ($rec->{level} == 524288 and $rec->{target} eq '' and !defined $rec->{server}) { if ($text =~ /^\Q${keybind_to_check}\E\s+(.+?)\s*$/) { $existing_binding = $1; } Irssi::signal_stop(); } } sub check_for_existing_bind { my ($keys) = @_; $keybind_to_check = $keys; $existing_binding = undef; Irssi::signal_add_first('print text' => \&check_existing_binds); Irssi::command("bind $keybind_to_check"); Irssi::signal_remove('print text' => \&check_existing_binds); return $existing_binding; } ## KEYMAP HANDLERS ############################################################# sub create_keymapping { my ($keys, $name, $chatnet) = @_; my $cmd = 'command ' . get_go_command($name, $chatnet); if ($keys =~ /(meta-.)-.+/ and !exists($leadkeys{$1})) { if (my $bind = check_for_existing_bind($1)) { if ($clear_composites) { warning("Removing bind from $1 to '$bind' as instructed"); Irssi::command("^bind -delete $1"); $leadkeys{$1} = $bind; } else { error("$1 is bound to '$bind' and cannot be used in composite keybinding", 1); return 0; } } } Irssi::command("^bind $keys $cmd"); return 1; } sub check_create_keymapping { my ($keys, $name, $chatnet) = @_; my $cmd = 'command ' . get_go_command($name, $chatnet); my $bind = check_for_existing_bind($keys); if ($bind and $bind ne $cmd) { if ($overwrite_binds) { warning("Overwriting bind from $keys to '$bind' as instructed"); } else { error("Key $keys already bound to '$bind', please remove first.", 1); return 0; } } return create_keymapping($keys, $name, $chatnet); } sub add_keymapping { my ($keys, $name, $chatnet) = @_; if (check_create_keymapping($keys, $name, $chatnet)) { $name = channet_pair_to_string($name, $chatnet); debug("Key binding created: $keys → $name", 1); return 1; } return 0; } sub remove_keymapping { my ($keys) = @_; my $bind = check_for_existing_bind($keys); if (!$bind) { error("No chankey mapping for $keys"); return; } my $item = lookup_item_by_keys($keys); if ($item) { Irssi::command("^bind -delete $keys"); return $bind; } else { error("The key binding for '$keys' is not a chankeys binding: $bind"); return; } } sub lookup_item_by_keys { my ($data) = @_; my $ret; while (my ($item, $keys) = each %itemmap) { $ret = $item if ($keys eq $data); # do not call last or the iterator won't be reset } return $ret; } sub remove_existing_binds { while (my ($item, $keys) = each %itemmap) { Irssi::command("^bind -delete $keys"); } %leadkeys = (); } ### SAVING AND LOADING ######################################################### sub get_mappings_fh { my ($filename) = @_; my $fh; if (! -e $filename) { save_mappings($filename); info("Created new/empty mappings file: $filename"); } open($fh, '<', $filename) || error("Cannot open mappings file: $!"); return $fh; } sub load_mappings { my ($filename) = @_; %itemmap = (); my $fh = get_mappings_fh($filename); my $firstline = <$fh> || error("Cannot read from $filename.");; my $version; if ($firstline =~ m/^;+\s+chankeys keymap file \(version: *([\d.]+)\)/) { $version = $1; } else { error("First line of $filename is not a chankey header."); } my $l = 1; while (<$fh>) { $l++; next if m/^\s*(?:;|$)/; my ($item, $keys, $rest) = split; if ($rest) { error("Cannot parse $filename:$l: $_"); return; } $itemmap{$item} = $keys; } close($fh) || error("Cannot close mappings file: $!"); } sub save_mappings { my ($filename) = @_; open(FH, '+>', $filename) || error("Cannot create mappings file: $!"); print FH <<"EOF"; ; chankeys keymap file (version: $_VERSION) ; ; WARNING: this file will be overwritten on /save, ; use "/set chankey_autosave off" to avoid. ; ; item: channel name (optionally chatnet/#channel) or query partner ; keys: key combination ; ; item keys EOF foreach my $name (sort keys(%itemmap)) { my $keys = $itemmap{$name}; print FH "$name\t$keys\n"; } print FH <<"EOF"; ; EXAMPLES ; ;;; associate meta-s-meta-i with the #irssi channel ; libera/#irssi meta-s-meta-i ; ;;; associate F12 with the bitlbee control window ; &bitlbee F12 ; ;;; associate meta-\ with a query ; bitlbee/sgs7e meta-\\ ; vim:noet:tw=0:ts=48:com=b\\:; EOF close(FH); } ## COMMAND HANDLERS ############################################################ sub chankey_add { my ($data, $server, $witem) = @_; my ($keys, $name, $chatnet) = split /\s+/, $data; if ($name) { ($name, $chatnet) = string_to_channet_pair($name) unless $chatnet; } else { if (!$witem) { error("No active window item to add a channel key for", 1); return; } $name = $witem->{name}; $chatnet = $server->{chatnet}; } if (add_keymapping($keys, $name, $chatnet)) { $itemmap{channet_pair_to_string($name, $chatnet)} = $keys; $changed_since_last_save = 1; } } sub chankey_remove { my ($data) = @_; return unless $data; my $bind = remove_keymapping($data); if ($bind) { debug("Key binding removed: $data (was: $bind)"); my $item = lookup_item_by_keys($data); delete($itemmap{$item}); $changed_since_last_save = 1; } } sub chankey_list { return unless %itemmap; info("Key bindings I know about:", 1); foreach my $item (sort keys %itemmap) { my $keys = $itemmap{$item}; my $active; if (my $bind = check_for_existing_bind($keys)) { my ($name, $chatnet) = string_to_channet_pair($item); $active = $bind eq ('command ' . get_go_command($name, $chatnet)); } my $out = sprintf("%13s %1s %s", $keys, $active ? '→' : '', $item); info($out, 1); } } sub chankey_load { remove_existing_binds(); load_mappings($map_file); my $cnt = scalar(keys %itemmap); foreach my $channel (Irssi::channels, Irssi::queries) { my $name = $channel->{name}; my $chatnet = $channel->{server}->{chatnet}; if (my @keymap = get_keymap_for_channet_pair($name, $chatnet)) { create_keymapping(@keymap); } } $changed_since_last_save = 0; info("Loaded $cnt mappings from $map_file"); } sub chankey_save { my ($args) = @_; if (!$changed_since_last_save and $args ne '-force') { info("Not saving unchanged mappings without -force"); return; } autosave(1); } sub chankey_goto { my ($args) = @_; my ($name, $chatnet) = split /\s+/, $args; my $cmd = get_go_command($name, $chatnet); Irssi::command("^$cmd"); } Irssi::command_bind('chankeys add', \&chankey_add); Irssi::command_bind('chankeys remove', \&chankey_remove); Irssi::command_bind('chankeys list', \&chankey_list); Irssi::command_bind('chankeys reload', \&chankey_load); Irssi::command_bind('chankeys save', \&chankey_save); Irssi::command_bind('chankeys goto', \&chankey_goto); Irssi::command_bind('chankeys help', \&chankey_help); Irssi::command_bind('chankeys', sub { my ( $data, $server, $item ) = @_; $data =~ s/\s+$//g; if ($data) { Irssi::command_runsub('chankeys', $data, $server, $item); } else { chankey_help(); } } ); Irssi::command_bind('help', sub { $_[0] =~ s/\s+$//g; return unless $_[0] eq 'chankeys'; chankey_help(); Irssi::signal_stop(); } ); sub chankey_help { my ($data, $server, $item) = @_; Irssi::print (<<"SCRIPTHELP_EOF", MSGLEVEL_CLIENTCRAP); %_chankeys $_VERSION - associate key shortcuts with channels %U%_Synopsis%_%U %_CHANKEYS ADD%_ <%Ukeybinding%U> [<%Uchannel%U>] [<%Uchatnet%U>] %_CHANKEYS REMOVE%_ <%Ukeybinding%U> %_CHANKEYS LIST%_ %_CHANKEYS [RE]LOAD%_ %_CHANKEYS SAVE%_ [-force] %_CHANKEYS GOTO%_ <%Uchannel%U> [<%Uchatnet%U>] %_CHANKEYS HELP%_ <%Ukeybinding%U> %| Key(s) to bind. Refer to %_/HELP BIND%_ for format <%Uchannel%U> %| Channel name to associate. Can include %_/chatnet%. <%Uchatnet%U> %| The chatnet of the channel. Not generally supported. %U%_Settings%_%U /set %_chankeys_go_command%_ [$go_command] %| The command to use to switch to a matching window item. The only reason %| you might need to set this is if you have channels with the same name %| across different chatnets. In this case, you need to load the go2.pl %| module, and set this to "go \$C \$chatnet", because "window goto" cannot %| incorporate the chatnet (yet). Beware that this will prevent %| adv_windowlist.pl from reading out the keybinding to use for the %| statusbar. /set %_chankeys_overwrite_binds%_ [$overwrite_binds] %| When chankey encounters an existing key mapping, it refuses to overwrite %| it unless this is switched on. /set %_chankeys_clear_composites%_ [$clear_composites] %| A mapping like meta-s-meta-i will not work if meta-s is bound to something %| already, and chankey will check and fail in such a case. Setting this %| to on will make chankeys remove the existing mapping, such that the %| composite mapping works. /set %_chankeys_autosave%_ [$autosave] %| Skip saving/overwriting the chankeys setup to file if you prefer to %| maintain the mappings outside of irssi. /set %_chankeys_debug%_ [$debug] %| Turns on debug output. Not that this may itself be buggy, so please don't %| use it unless you really need it. %U%_Examples%_%U Associate %_meta-d-meta-d%_ with the current channel %|%#/%_CHANKEYS ADD%_ meta-d-meta-d Associate F12 with the &bitlbee window %|%#/%_BIND%_ ^[[24~ key F12 %|%#/%_CHANKEYS ADD%_ F12 &bitlbee Associate %_meta-m-meta-m%_ with the #matrix channel on LiberaChat %|%#/%_CHANKEYS ADD%_ meta-m-meta-m #matrix LiberaChat Alternative form to specify chatnet %|%#/%_CHANKEYS ADD%_ meta-m-meta-m #matrix/LiberaChat Save mappings to file ($map_file), using -force to write even if nothing has changed: %|%#/%_CHANKEYS SAVE%_ -force Load mappings from file ($map_file): %|%#/%_CHANKEYS LOAD%_ List all known key associations %|%#/%_CHANKEYS LIST%_ SCRIPTHELP_EOF } ## SIGNAL HANDLERS ############################################################# sub on_channel_created { my ($chanrec, $auto) = @_; my $name = $chanrec->{name}; my $chatnet = $chanrec->{server}->{chatnet}; my @keymap = get_keymap_for_channet_pair($name, $chatnet); add_keymapping(@keymap) if @keymap; } Irssi::signal_add('channel created' => \&on_channel_created); Irssi::signal_add('query created' => \&on_channel_created); sub on_channel_destroyed { my ($chanrec) = @_; my $name = $chanrec->{name}; my $chatnet = $chanrec->{server}->{chatnet}; my ($keys, undef, undef) = get_keymap_for_channet_pair($name, $chatnet); remove_keymapping($keys) if $keys; } Irssi::signal_add('channel destroyed' => \&on_channel_destroyed); Irssi::signal_add('query destroyed' => \&on_channel_destroyed); sub autosave { my ($force) = @_; return unless $changed_since_last_save or $force; if (!$autosave) { info("Not saving mappings due to chankeys_autosave setting"); return; } info("Saving mappings to $map_file"); save_mappings($map_file); $changed_since_last_save = 0; } sub UNLOAD { autosave(); } Irssi::signal_add('setup saved', \&autosave); Irssi::signal_add('setup reread', \&chankey_load); ## INIT ######################################################################## chankey_load();