diff options
Diffstat (limited to 'scripts/chankeys.pl')
-rw-r--r-- | scripts/chankeys.pl | 570 |
1 files changed, 570 insertions, 0 deletions
diff --git a/scripts/chankeys.pl b/scripts/chankeys.pl new file mode 100644 index 0000000..0dfb782 --- /dev/null +++ b/scripts/chankeys.pl @@ -0,0 +1,570 @@ +# chankeys.pl — Irssi script for associating key shortcuts with channels +# +# © 2021–22 martin f. krafft <madduck@madduck.net> +# 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(); |