1 /++
2     A collection of functions that relate to applying ANSI effects to text.
3 
4     This submodule has to do with terminal text colouring and is therefore
5     gated behind version `Colours`.
6 
7     Example:
8     ---
9     Appender!(char[]) sink;
10 
11     // Output range version
12     sink.put("Hello ");
13     sink.applyANSI(TerminalForeground.red, ANSICodeType.foreground);
14     sink.put("world!");
15     sink.applyANSI(TerminalForeground.default_, ANSICodeType.foreground);
16 
17     with (TerminalForeground)
18     {
19         // Normal string-returning versions
20         writeln("Hello ", red.asANSI, "world!", default_.asANSI);
21         writeln("H3LL0".withANSI(red), ' ', "W0RLD!".withANSI(default_));
22     }
23 
24     // Also accepts RGB form
25     sink.put(" Also");
26     sink.applyTruecolour(128, 128, 255);
27     sink.put("magic");
28     sink.applyANSI(TerminalForeground.default_);
29 
30     with (TerminalForeground)
31     {
32         writeln("Also ", asTruecolour(128, 128, 255), "magic", default_.asANSI);
33     }
34 
35     immutable line = "Surrounding text kameloso surrounding text";
36     immutable kamelosoInverted = line.invert("kameloso");
37     assert(line != kamelosoInverted);
38 
39     immutable nicknameTint = "nickname".getColourByHash(*kameloso.common.settings);
40     immutable substringTint = "substring".getColourByHash(*kameloso.common.settings);
41     ---
42 
43     It is used heavily in the Printer plugin, to format sections of its output
44     in different colours, but it's generic enough to use anywhere.
45 
46     The output range versions are cumbersome but necessary to minimise the number
47     of strings generated.
48 
49     See_Also:
50         [kameloso.terminal.colours.defs],
51         [kameloso.terminal.colours.tags],
52         [kameloso.terminal]
53 
54     Copyright: [JR](https://github.com/zorael)
55     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
56 
57     Authors:
58         [JR](https://github.com/zorael)
59  +/
60 module kameloso.terminal.colours;
61 
62 version(Colours):
63 
64 private:
65 
66 import kameloso.terminal : TerminalToken;
67 import kameloso.terminal.colours.defs : ANSICodeType;
68 import kameloso.pods : CoreSettings;
69 import std.range : isOutputRange;
70 import std.typecons : Flag, No, Yes;
71 
72 public:
73 
74 
75 // applyANSI
76 /++
77     Applies an ANSI code to a passed output range.
78 
79     Example:
80     ---
81     Appender!(char[]) sink;
82 
83     sink.put("Hello ");
84     sink.applyANSI(TerminalForeground.red, ANSICodeType.foreground);
85     sink.put("world!");
86     sink.applyANSI(TerminalForeground.default_, ANSICodeType.foreground);
87     ---
88 
89     Params:
90         sink = Output range sink to write to.
91         code = ANSI code to apply.
92         overrideType = Force a specific [kameloso.terminal.colours.defs.ANSICodeType|ANSICodeType]
93             in cases where there is ambiguity.
94  +/
95 void applyANSI(Sink)
96     (auto ref Sink sink,
97     const uint code,
98     const ANSICodeType overrideType = ANSICodeType.unset)
99 if (isOutputRange!(Sink, char[]))
100 {
101     import lu.conv : toAlphaInto;
102 
103     void putBasic()
104     {
105         code.toAlphaInto(sink);
106     }
107 
108     void putExtendedForegroundColour()
109     {
110         enum foregroundPrelude = "38;5;";
111         sink.put(foregroundPrelude);
112         code.toAlphaInto(sink);
113     }
114 
115     void putExtendedBackgroundColour()
116     {
117         enum backgroundPrelude = "48;5;";
118         sink.put(backgroundPrelude);
119         code.toAlphaInto(sink);
120     }
121 
122     sink.put(cast(char)TerminalToken.format);
123     sink.put('[');
124     scope(exit) sink.put('m');
125 
126     with (ANSICodeType)
127     final switch (overrideType)
128     {
129     case foreground:
130         if (((code >= 30) && (code <= 39)) ||
131             ((code >= 90) && (code <= 97)))
132         {
133             // Basic foreground colour
134             return putBasic();
135         }
136         else
137         {
138             // Extended foreground colour
139             return putExtendedForegroundColour();
140         }
141 
142     case background:
143         if (((code >= 40) && (code <= 49)) ||
144             ((code >= 100) && (code <= 107)))
145         {
146             // Basic background colour
147             return putBasic();
148         }
149         else
150         {
151             // Extended background colour
152             return putExtendedBackgroundColour();
153         }
154 
155     case format:
156     case reset:
157         return putBasic();
158 
159     case unset:
160         // Infer as best as possible
161         switch (code)
162         {
163         case 1:
164         ..
165         case 8:
166             // Format
167             goto case;
168 
169         case 0:
170         case 21:
171         ..
172         case 28:
173             // Reset token
174             goto case;
175 
176         case 40:
177         ..
178         case 49:
179         case 100:
180         ..
181         case 107:
182             // Background colour
183             //enum backgroundPrelude = "48;5;";
184             //sink.put(backgroundPrelude);
185             goto case;
186 
187         case 30:
188         ..
189         case 39:
190         case 90:
191         ..
192         case 97:
193             // Basic foreground colour
194             return putBasic();
195 
196         default:
197             // Extended foreground colour
198             return putExtendedForegroundColour();
199         }
200     }
201 }
202 
203 
204 // withANSI
205 /++
206     Applies an ANSI code to a passed string and returns it as a new one.
207     Convenience function to colour a piece of text without being passed an
208     output sink to fill into.
209 
210     Example:
211     ---
212     with (TerminalForeground)
213     {
214         // Normal string-returning versions
215         writeln("Hello ", red.asANSI, "world!", default_.asANSI);
216         writeln("H3LL0".withANSI(red), ' ', "W0RLD!".withANSI(default_));
217     }
218     ---
219 
220     Params:
221         text = Original string.
222         code = ANSI code.
223         overrideType = Force a specific [kameloso.terminal.colours.defs.ANSICodeType|ANSICodeType]
224             in cases where there is ambiguity.
225 
226     Returns:
227         A new string consisting of the passed `text` argument, but with the supplied
228         ANSI code applied.
229  +/
230 string withANSI(
231     const string text,
232     const uint code,
233     const ANSICodeType overrideType = ANSICodeType.unset) pure @safe nothrow
234 {
235     import kameloso.terminal.colours.defs : TerminalReset;
236     import std.array : Appender;
237 
238     Appender!(char[]) sink;
239     sink.reserve(text.length + 8);
240     sink.applyANSI(code, overrideType);
241     sink.put(text);
242     sink.applyANSI(TerminalReset.all);
243     return sink.data;
244 }
245 
246 
247 // asANSI
248 /++
249     Returns an ANSI format sequence containing the passed code.
250 
251     Params:
252         code = ANSI code.
253 
254     Returns:
255         A string containing the passed ANSI `code` as an ANSI sequence.
256  +/
257 string asANSI(const uint code) pure @safe nothrow
258 {
259     import std.array : Appender;
260 
261     Appender!(char[]) sink;
262     sink.reserve(16);
263     sink.applyANSI(code);
264     return sink.data;
265 }
266 
267 
268 // normaliseColoursBright
269 /++
270     Takes a colour and, if it deems it is too bright to see on a light terminal
271     background, makes it darker.
272 
273     Example:
274     ---
275     int r = 255;
276     int g = 128;
277     int b = 100;
278     normaliseColoursBright(r, g, b);
279     assert(r != 255);
280     assert(g != 128);
281     assert(b != 100);
282     ---
283 
284     Params:
285         r = Reference to a red value.
286         g = Reference to a green value.
287         b = Reference to a blue value.
288  +/
289 private void normaliseColoursBright(ref uint r, ref uint g, ref uint b) pure @safe nothrow @nogc
290 {
291     enum pureWhiteReplacement = 120;
292     enum pureWhiteRange = 200;
293 
294     enum darkenUpperLimit = 255;
295     enum darkenLowerLimit = 200;
296     enum darken = 45;
297 
298     // Sanity check
299     if (r > 255) r = 255;
300     if (g > 255) g = 255;
301     if (b > 255) b = 255;
302 
303     if ((r + g + b) == 3*255)
304     {
305         // Specialcase pure white, set to grey and return
306         r = pureWhiteReplacement;
307         g = pureWhiteReplacement;
308         b = pureWhiteReplacement;
309         return;
310     }
311 
312     // Darken high colours at high levels
313     r -= ((r <= darkenUpperLimit) && (r > darkenLowerLimit)) * darken;
314     g -= ((g <= darkenUpperLimit) && (g > darkenLowerLimit)) * darken;
315     b -= ((b <= darkenUpperLimit) && (b > darkenLowerLimit)) * darken;
316 
317     if ((r > pureWhiteRange) && (b > pureWhiteRange) && (g > pureWhiteRange))
318     {
319         r = pureWhiteReplacement;
320         g = pureWhiteReplacement;
321         b = pureWhiteReplacement;
322     }
323 
324     // Sanity check
325     if (r > 255) r = 255;
326     if (g > 255) g = 255;
327     if (b > 255) b = 255;
328 }
329 
330 
331 // normaliseColours
332 /++
333     Takes a colour and, if it deems it is too dark to see on a black terminal
334     background, makes it brighter.
335 
336     Example:
337     ---
338     int r = 255;
339     int g = 128;
340     int b = 100;
341     normaliseColours(r, g, b);
342     assert(r != 255);
343     assert(g != 128);
344     assert(b != 100);
345     ---
346 
347     Params:
348         r = Reference to a red value.
349         g = Reference to a green value.
350         b = Reference to a blue value.
351  +/
352 private void normaliseColours(ref uint r, ref uint g, ref uint b) pure @safe nothrow @nogc
353 {
354     enum pureBlackReplacement = 120;
355 
356     enum tooDarkThreshold = 100;
357     enum tooDarkIncrement = 40;
358 
359     enum tooBlue = 130;
360     enum tooBlueOtherColourThreshold = 45;
361 
362     enum highlight = 40;
363 
364     enum darkenThreshold = 240;
365     enum darken = 20;
366 
367     // Sanity check
368     if (r > 255) r = 255;
369     if (g > 255) g = 255;
370     if (b > 255) b = 255;
371 
372     if ((r + g + b) == 0)
373     {
374         // Specialcase pure black, set to grey and return
375         r = pureBlackReplacement;
376         g = pureBlackReplacement;
377         b = pureBlackReplacement;
378         return;
379     }
380 
381     // Raise all low colours
382     r += (r < tooDarkThreshold) * tooDarkIncrement;
383     g += (g < tooDarkThreshold) * tooDarkIncrement;
384     b += (b < tooDarkThreshold) * tooDarkIncrement;
385 
386     // Make dark colours more vibrant
387     r += ((r > g) & (r > b)) * highlight;
388     g += ((g > b) & (g > r)) * highlight;
389     b += ((b > g) & (b > r)) * highlight;
390 
391     // Whitewash blue slightly
392     if ((b > tooBlue) && (r < tooBlueOtherColourThreshold) && (g < tooBlueOtherColourThreshold))
393     {
394         r += tooBlueOtherColourThreshold;
395         g += tooBlueOtherColourThreshold;
396     }
397 
398     // Make bright colours more biased toward one colour
399     r -= ((r > darkenThreshold) && ((r < b) | (r < g))) * darken;
400     g -= ((g > darkenThreshold) && ((g < r) | (g < b))) * darken;
401     b -= ((b > darkenThreshold) && ((b < r) | (b < g))) * darken;
402 
403     // Sanity check
404     if (r > 255) r = 255;
405     if (g > 255) g = 255;
406     if (b > 255) b = 255;
407 }
408 
409 version(none)
410 unittest
411 {
412     import std.conv : to;
413     import std.stdio : write, writeln;
414 
415     enum bright = Yes.brightTerminal;
416     // ▄█▀
417 
418     writeln("BRIGHT: ", bright);
419 
420     foreach (r; 0..256)
421     {
422         immutable n = r % 10;
423         write(n.to!string.truecolour(r, 0, 0, bright));
424         if (n == 0) write(r);
425     }
426 
427     writeln();
428 
429     foreach (g; 0..256)
430     {
431         immutable n = g % 10;
432         write(n.to!string.truecolour(0, g, 0, bright));
433         if (n == 0) write(g);
434     }
435 
436     writeln();
437 
438     foreach (b; 0..256)
439     {
440         immutable n = b % 10;
441         write(n.to!string.truecolour(0, 0, b, bright));
442         if (n == 0) write(b);
443     }
444 
445     writeln();
446 
447     foreach (rg; 0..256)
448     {
449         immutable n = rg % 10;
450         write(n.to!string.truecolour(rg, rg, 0, bright));
451         if (n == 0) write(rg);
452     }
453 
454     writeln();
455 
456     foreach (rb; 0..256)
457     {
458         immutable n = rb % 10;
459         write(n.to!string.truecolour(rb, 0, rb, bright));
460         if (n == 0) write(rb);
461     }
462 
463     writeln();
464 
465     foreach (gb; 0..256)
466     {
467         immutable n = gb % 10;
468         write(n.to!string.truecolour(0, gb, gb, bright));
469         if (n == 0) write(gb);
470     }
471 
472     writeln();
473 
474     foreach (rgb; 0..256)
475     {
476         immutable n = rgb % 10;
477         write(n.to!string.truecolour(rgb, rgb, rgb, bright));
478         if (n == 0) write(rgb);
479     }
480 
481     writeln();
482 }
483 
484 
485 // applyTruecolour
486 /++
487     Produces a terminal colour token for the colour passed, expressed in terms
488     of red, green and blue, then writes it to the passed output range.
489 
490     Example:
491     ---
492     Appender!(char[]) sink;
493     int r, g, b;
494     numFromHex("3C507D", r, g, b);
495     sink.applyTruecolour(r, g, b);
496     sink.put("Foo");
497     sink.applyANSI(TerminalReset.all);
498     writeln(sink);  // "Foo" in #3C507D
499     ---
500 
501     Params:
502         sink = Output range to write the final code into.
503         r = Red value.
504         g = Green value.
505         b = Blue value.
506         bright = Whether the terminal has a bright background or not.
507         normalise = Whether or not to normalise colours so that they aren't too
508             dark or too bright.
509  +/
510 void applyTruecolour(Sink)
511     (auto ref Sink sink,
512     uint r,
513     uint g,
514     uint b,
515     const Flag!"brightTerminal" bright = No.brightTerminal,
516     const Flag!"normalise" normalise = Yes.normalise)
517 if (isOutputRange!(Sink, char[]))
518 {
519     import lu.conv : toAlphaInto;
520 
521     // \033[
522     // 38 foreground
523     // 2 truecolour?
524     // r;g;bm
525 
526     if (normalise)
527     {
528         if (bright)
529         {
530             normaliseColoursBright(r, g, b);
531         }
532         else
533         {
534             normaliseColours(r, g, b);
535         }
536     }
537 
538     sink.put(cast(char)TerminalToken.format);
539     sink.put("[38;2;");
540     r.toAlphaInto(sink);
541     sink.put(';');
542     g.toAlphaInto(sink);
543     sink.put(';');
544     b.toAlphaInto(sink);
545     sink.put('m');
546 }
547 
548 
549 // asTruecolour
550 /++
551     Produces a terminal colour token for the colour passed, expressed in terms
552     of red, green and blue. Convenience function to colour a piece of text
553     without being passed an output sink to fill into.
554 
555     Example:
556     ---
557     string foo = "Foo Bar".asTruecolour(172, 172, 255);
558 
559     int r, g, b;
560     numFromHex("003388", r, g, b);
561     string bar = "Bar Foo".asTruecolour(r, g, b);
562     ---
563 
564     Params:
565         word = String to tint.
566         r = Red value.
567         g = Green value.
568         b = Blue value.
569         bright = Whether the terminal has a bright background or not.
570         normalise = Whether or not to normalise colours so that they aren't too
571             dark or too bright.
572 
573     Returns:
574         The passed string word encompassed by terminal colour tags.
575  +/
576 string asTruecolour(
577     const string word,
578     const uint r,
579     const uint g,
580     const uint b,
581     const Flag!"brightTerminal" bright = No.brightTerminal,
582     const Flag!"normalise" normalise = Yes.normalise) pure @safe
583 {
584     import kameloso.terminal.colours.defs : TerminalReset;
585     import std.array : Appender;
586 
587     Appender!(char[]) sink;
588     // \033[38;2;255;255;255m<word>\033[m
589     // \033[48 for background
590     sink.reserve(word.length + 23);
591 
592     sink.applyTruecolour(r, g, b, bright, normalise);
593     sink.put(word);
594     sink.applyANSI(TerminalReset.all);
595     return sink.data;
596 }
597 
598 ///
599 unittest
600 {
601     import std.format : format;
602 
603     immutable name = "blarbhl".asTruecolour(255, 255, 255, No.brightTerminal, No.normalise);
604     immutable alsoName = "%c[38;2;%d;%d;%dm%s%c[0m"
605         .format(cast(char)TerminalToken.format, 255, 255, 255,
606            "blarbhl", cast(char)TerminalToken.format);
607 
608     assert((name == alsoName), alsoName);
609 }
610 
611 
612 // invert
613 /++
614     Terminal-inverts the colours of a piece of text in a string.
615 
616     Example:
617     ---
618     immutable line = "This is an example!";
619     writeln(line.invert("example"));  // "example" substring visually inverted
620     writeln(line.invert("EXAMPLE", Yes.caseInsensitive)); // "example" inverted as "EXAMPLE"
621     ---
622 
623     Params:
624         line = Line to examine and invert a substring of.
625         toInvert = Substring to invert.
626         caseSensitive = Whether or not to look for matches case-insensitively,
627             yet invert with the casing passed.
628 
629     Returns:
630         Line with the substring in it inverted, if inversion was successful,
631         else (a duplicate of) the line unchanged.
632  +/
633 string invert(
634     const string line,
635     const string toInvert,
636     const Flag!"caseSensitive" caseSensitive = Yes.caseSensitive) pure @safe
637 {
638     import kameloso.terminal.colours.defs : TerminalFormat, TerminalReset;
639     import dialect.common : isValidNicknameCharacter;
640     import std.array : Appender;
641     import std.format : format;
642     import std.string : indexOf;
643 
644     ptrdiff_t startpos;
645 
646     if (caseSensitive)
647     {
648         startpos = line.indexOf(toInvert);
649     }
650     else
651     {
652         import std.algorithm.searching : countUntil;
653         import std.uni : asLowerCase;
654         startpos = line.asLowerCase.countUntil(toInvert.asLowerCase);
655     }
656 
657     //assert((startpos != -1), "Tried to invert nonexistent text");
658     if (startpos == -1) return line;
659 
660     enum pattern = "%c[%dm%s%c[%dm";
661     immutable inverted = pattern.format(
662         TerminalToken.format,
663         TerminalFormat.reverse,
664         toInvert,
665         TerminalToken.format,
666         TerminalReset.invert);
667 
668     Appender!(char[]) sink;
669     sink.reserve(line.length + 16);
670     string slice = line;  // mutable
671 
672     uint i;
673 
674     do
675     {
676         immutable endpos = startpos + toInvert.length;
677 
678         if ((startpos == 0) && (i > 0))
679         {
680             // Not the first run and begins with the nick --> run-on nicks
681             sink.put(slice[0..endpos]);
682         }
683         else if (endpos == slice.length)
684         {
685             // Line ends with the string; break
686             sink.put(slice[0..startpos]);
687             sink.put(inverted);
688             //break;
689         }
690         else if ((startpos > 1) && slice[startpos-1].isValidNicknameCharacter)
691         {
692             // string is in the middle of a string, like abcTHISdef; skip
693             sink.put(slice[0..endpos]);
694         }
695         else if (slice[endpos].isValidNicknameCharacter)
696         {
697             // string ends with a nick character --> different nick; skip
698             sink.put(slice[0..endpos]);
699         }
700         else
701         {
702             // Begins at string start, or trailed by non-nickname character
703             sink.put(slice[0..startpos]);
704             sink.put(inverted);
705         }
706 
707         ++i;
708         slice = slice[endpos..$];
709         startpos = slice.indexOf(toInvert);
710     }
711     while (startpos != -1);
712 
713     // Add the remainder, from the last match to the end
714     sink.put(slice);
715 
716     return sink.data;
717 }
718 
719 ///
720 unittest
721 {
722     import kameloso.terminal.colours.defs : TerminalFormat, TerminalReset;
723     import std.format : format;
724 
725     immutable pre = "%c[%dm".format(TerminalToken.format, TerminalFormat.reverse);
726     immutable post = "%c[%dm".format(TerminalToken.format, TerminalReset.invert);
727 
728     {
729         immutable line = "abc".invert("abc");
730         immutable expected = pre ~ "abc" ~ post;
731         assert((line == expected), line);
732     }
733     {
734         immutable line = "abc abc".invert("abc");
735         immutable inverted = pre ~ "abc" ~ post;
736         immutable expected = inverted ~ ' ' ~ inverted;
737         assert((line == expected), line);
738     }
739     {
740         immutable line = "abca abc".invert("abc");
741         immutable inverted = pre ~ "abc" ~ post;
742         immutable expected = "abca " ~ inverted;
743         assert((line == expected), line);
744     }
745     {
746         immutable line = "abcabc".invert("abc");
747         immutable expected = "abcabc";
748         assert((line == expected), line);
749     }
750     {
751         immutable line = "kameloso^^".invert("kameloso");
752         immutable expected = "kameloso^^";
753         assert((line == expected), line);
754     }
755     {
756         immutable line = "foo kameloso bar".invert("kameloso");
757         immutable expected = "foo " ~ pre ~ "kameloso" ~ post ~ " bar";
758         assert((line == expected), line);
759     }
760     {
761         immutable line = "fookameloso bar".invert("kameloso");
762         immutable expected = "fookameloso bar";
763         assert((line == expected), line);
764     }
765     {
766         immutable line = "foo kamelosobar".invert("kameloso");
767         immutable expected = "foo kamelosobar";
768         assert((line == expected), line);
769     }
770     {
771         immutable line = "foo(kameloso)bar".invert("kameloso");
772         immutable expected = "foo(" ~ pre ~ "kameloso" ~ post ~ ")bar";
773         assert((line == expected), line);
774     }
775     {
776         immutable line = "kameloso: 8ball".invert("kameloso");
777         immutable expected = pre ~ "kameloso" ~ post ~ ": 8ball";
778         assert((line == expected), line);
779     }
780     {
781         immutable line = "Welcome to the freenode Internet Relay Chat Network kameloso^"
782             .invert("kameloso^");
783         immutable expected = "Welcome to the freenode Internet Relay Chat Network " ~
784             pre ~ "kameloso^" ~ post;
785         assert((line == expected), line);
786     }
787     {
788         immutable line = "kameloso^: wfwef".invert("kameloso^");
789         immutable expected = pre ~ "kameloso^" ~ post ~ ": wfwef";
790         assert((line == expected), line);
791     }
792     {
793         immutable line = "[kameloso^]".invert("kameloso^");
794         immutable expected = "[kameloso^]";
795         assert((line == expected), line);
796     }
797     {
798         immutable line = `"kameloso^"`.invert("kameloso^");
799         immutable expected = "\"" ~ pre ~ "kameloso^" ~ post ~ "\"";
800         assert((line == expected), line);
801     }
802     {
803         immutable line = "kameloso^".invert("kameloso");
804         immutable expected = "kameloso^";
805         assert((line == expected), line);
806     }
807     {
808         immutable line = "That guy kameloso? is a bot".invert("kameloso");
809         immutable expected = "That guy " ~ pre ~ "kameloso" ~ post  ~ "? is a bot";
810         assert((line == expected), line);
811     }
812     {
813         immutable line = "kameloso`".invert("kameloso");
814         immutable expected = "kameloso`";
815         assert((line == expected), line);
816     }
817     {
818         immutable line = "kameloso9".invert("kameloso");
819         immutable expected = "kameloso9";
820         assert((line == expected), line);
821     }
822     {
823         immutable line = "kameloso-".invert("kameloso");
824         immutable expected = "kameloso-";
825         assert((line == expected), line);
826     }
827     {
828         immutable line = "kameloso_".invert("kameloso");
829         immutable expected = "kameloso_";
830         assert((line == expected), line);
831     }
832     {
833         immutable line = "kameloso_".invert("kameloso_");
834         immutable expected = pre ~ "kameloso_" ~ post;
835         assert((line == expected), line);
836     }
837     {
838         immutable line = "kameloso kameloso kameloso kameloso kameloso".invert("kameloso");
839         immutable expected = "%1$skameloso%2$s %1$skameloso%2$s %1$skameloso%2$s %1$skameloso%2$s %1$skameloso%2$s"
840             .format(pre, post);
841         assert((line == expected), line);
842     }
843 
844     // Case-insensitive tests
845 
846     {
847         immutable line = "KAMELOSO".invert("kameloso", No.caseSensitive);
848         immutable expected = pre ~ "kameloso" ~ post;
849         assert((line == expected), line);
850     }
851     {
852         immutable line = "KamelosoTV".invert("kameloso", No.caseSensitive);
853         immutable expected = "KamelosoTV";
854         assert((line == expected), line);
855     }
856     {
857         immutable line = "Blah blah kAmElOsO Blah blah".invert("kameloso", No.caseSensitive);
858         immutable expected = "Blah blah " ~ pre ~ "kameloso" ~ post ~ " Blah blah";
859         assert((line == expected), line);
860     }
861     {
862         immutable line = "Blah blah".invert("kameloso");
863         immutable expected = "Blah blah";
864         assert((line == expected), line);
865     }
866     {
867         immutable line = "".invert("kameloso");
868         immutable expected = "";
869         assert((line == expected), line);
870     }
871     {
872         immutable line = "KAMELOSO".invert("kameloso");
873         immutable expected = "KAMELOSO";
874         assert((line == expected), line);
875     }
876 }
877 
878 
879 // getColourByHash
880 /++
881     Hashes the passed string and picks an ANSI colour for it by modulo.
882 
883     Picks any colour, taking care not to pick black or white based on
884     the passed [kameloso.pods.CoreSettings|CoreSettings] struct (which has a
885     field that signifies a bright terminal background).
886 
887     Example:
888     ---
889     immutable nickColour = "kameloso".getColourByHash(*kameloso.common.settings);
890     ---
891 
892     Params:
893         word = String to hash and base colour on.
894         settings = A copy of the program-global [kameloso.pods.CoreSettings|CoreSettings].
895 
896     Returns:
897         A `uint` that can be used in an ANSI foreground colour sequence.
898  +/
899 auto getColourByHash(const string word, const CoreSettings settings) pure @safe /*@nogc*/ nothrow
900 in (word.length, "Tried to get colour by hash but no word was given")
901 {
902     import kameloso.irccolours : ircANSIColourMap;
903     import kameloso.terminal.colours.defs : TerminalForeground;
904     import std.traits : EnumMembers;
905 
906     static immutable basicForegroundMembers = [ EnumMembers!TerminalForeground ];
907 
908     static immutable uint[basicForegroundMembers.length+(-2)] brightTableBasic =
909         TerminalForeground.black ~ basicForegroundMembers[2..$-1];
910 
911     static immutable uint[basicForegroundMembers.length+(-2)] darkTableBasic =
912         TerminalForeground.white ~ basicForegroundMembers[2..$-1];
913 
914     static immutable brightTableExtended = ()
915     {
916         uint[98] colourTable = ircANSIColourMap[1..$].dup;
917 
918         // Tweak colours, darken some very bright ones
919         colourTable[0] = TerminalForeground.black;
920         colourTable[11] = TerminalForeground.yellow;
921         colourTable[53] = 224;
922         colourTable[65] = 222;
923         colourTable[77] = 223;
924         colourTable[78] = 190;
925 
926         return colourTable;
927     }();
928 
929     static immutable darkTableExtended = ()
930     {
931         uint[98] colourTable = ircANSIColourMap[1..$].dup;
932 
933         // Tweak colours, brighten some very dark ones
934         colourTable[15] = 55;
935         colourTable[23] = 20;
936         colourTable[24] = 56;
937         colourTable[25] = 57;
938         colourTable[35] = 21;
939         colourTable[33] = 243;
940         colourTable[87] = 241;
941         colourTable[88] = 242;
942         colourTable[89] = 243;
943         colourTable[90] = 243;
944         colourTable[97] = 240;
945         return colourTable;
946     }();
947 
948     const table = settings.extendedColours ?
949         settings.brightTerminal ?
950             brightTableExtended :
951             darkTableExtended
952             :
953         settings.brightTerminal ?
954             brightTableBasic :
955             darkTableBasic;
956 
957     immutable colourIndex = (hashOf(word) % table.length);
958     return table[colourIndex];
959 }
960 
961 ///
962 unittest
963 {
964     import std.conv : to;
965 
966     CoreSettings brightSettings;
967     CoreSettings darkSettings;
968     brightSettings.brightTerminal = true;
969 
970     {
971         immutable hash = getColourByHash("kameloso", darkSettings);
972         assert((hash == 227), hash.to!string);
973     }
974     {
975         immutable hash = getColourByHash("kameloso^", darkSettings);
976         assert((hash == 46), hash.to!string);
977     }
978     {
979         immutable hash = getColourByHash("zorael", brightSettings);
980         assert((hash == 35), hash.to!string);
981     }
982     {
983         immutable hash = getColourByHash("NO", brightSettings);
984         assert((hash == 90), hash.to!string);
985     }
986 }
987 
988 
989 // colourByHash
990 /++
991     Shorthand function to colour a passed word by the hash of it.
992 
993     Params:
994         word = String to colour.
995         settings = A copy of the program-global [kameloso.pods.CoreSettings|CoreSettings].
996 
997     Returns:
998         `word`, now in colour based on the hash of its contents.
999  +/
1000 auto colourByHash(const string word, const CoreSettings settings) pure @safe nothrow
1001 {
1002     return word.withANSI(getColourByHash(word, settings));
1003 }
1004 
1005 ///
1006 unittest
1007 {
1008     import std.conv : to;
1009 
1010     CoreSettings brightSettings;
1011     CoreSettings darkSettings;
1012     brightSettings.brightTerminal = true;
1013 
1014     {
1015         immutable coloured = "kameloso".colourByHash(darkSettings);
1016         assert((coloured == "\033[38;5;227mkameloso\033[0m"), coloured);
1017     }
1018     {
1019         immutable coloured = "kameloso".colourByHash(brightSettings);
1020         assert((coloured == "\033[38;5;222mkameloso\033[0m"), coloured);
1021     }
1022     {
1023         immutable coloured = "zorael".colourByHash(darkSettings);
1024         assert((coloured == "\033[35mzorael\033[0m"), coloured);
1025     }
1026     {
1027         immutable coloured = "NO".colourByHash(brightSettings);
1028         assert((coloured == "\033[90mNO\033[0m"), coloured);
1029     }
1030 }