summaryrefslogtreecommitdiffstats
path: root/scripts/chankeys.pl
blob: 0dfb782025d49765fc6ef92bd026c76b205afdaf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
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();