Properly and efficiently use split utility headers
[oweals/minetest.git] / src / chat.cpp
1 /*
2 Minetest-c55
3 Copyright (C) 2011 celeron55, Perttu Ahola <celeron55@gmail.com>
4
5 This program is free software; you can redistribute it and/or modify
6 it under the terms of the GNU Lesser General Public License as published by
7 the Free Software Foundation; either version 2.1 of the License, or
8 (at your option) any later version.
9
10 This program is distributed in the hope that it will be useful,
11 but WITHOUT ANY WARRANTY; without even the implied warranty of
12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 GNU Lesser General Public License for more details.
14
15 You should have received a copy of the GNU Lesser General Public License along
16 with this program; if not, write to the Free Software Foundation, Inc.,
17 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18 */
19
20 #include "chat.h"
21 #include "debug.h"
22 #include <cassert>
23 #include <cctype>
24 #include <sstream>
25 #include "util/string.h"
26 #include "util/numeric.h"
27
28 ChatBuffer::ChatBuffer(u32 scrollback):
29         m_scrollback(scrollback),
30         m_unformatted(),
31         m_cols(0),
32         m_rows(0),
33         m_scroll(0),
34         m_formatted(),
35         m_empty_formatted_line()
36 {
37         if (m_scrollback == 0)
38                 m_scrollback = 1;
39         m_empty_formatted_line.first = true;
40 }
41
42 ChatBuffer::~ChatBuffer()
43 {
44 }
45
46 void ChatBuffer::addLine(std::wstring name, std::wstring text)
47 {
48         ChatLine line(name, text);
49         m_unformatted.push_back(line);
50
51         if (m_rows > 0)
52         {
53                 // m_formatted is valid and must be kept valid
54                 bool scrolled_at_bottom = (m_scroll == getBottomScrollPos());
55                 u32 num_added = formatChatLine(line, m_cols, m_formatted);
56                 if (scrolled_at_bottom)
57                         m_scroll += num_added;
58         }
59
60         // Limit number of lines by m_scrollback
61         if (m_unformatted.size() > m_scrollback)
62         {
63                 deleteOldest(m_unformatted.size() - m_scrollback);
64         }
65 }
66
67 void ChatBuffer::clear()
68 {
69         m_unformatted.clear();
70         m_formatted.clear();
71         m_scroll = 0;
72 }
73
74 u32 ChatBuffer::getLineCount() const
75 {
76         return m_unformatted.size();
77 }
78
79 u32 ChatBuffer::getScrollback() const
80 {
81         return m_scrollback;
82 }
83
84 const ChatLine& ChatBuffer::getLine(u32 index) const
85 {
86         assert(index < getLineCount());
87         return m_unformatted[index];
88 }
89
90 void ChatBuffer::step(f32 dtime)
91 {
92         for (u32 i = 0; i < m_unformatted.size(); ++i)
93         {
94                 m_unformatted[i].age += dtime;
95         }
96 }
97
98 void ChatBuffer::deleteOldest(u32 count)
99 {
100         u32 del_unformatted = 0;
101         u32 del_formatted = 0;
102
103         while (count > 0 && del_unformatted < m_unformatted.size())
104         {
105                 ++del_unformatted;
106
107                 // keep m_formatted in sync
108                 if (del_formatted < m_formatted.size())
109                 {
110                         assert(m_formatted[del_formatted].first);
111                         ++del_formatted;
112                         while (del_formatted < m_formatted.size() &&
113                                         !m_formatted[del_formatted].first)
114                                 ++del_formatted;
115                 }
116
117                 --count;
118         }
119
120         m_unformatted.erase(0, del_unformatted);
121         m_formatted.erase(0, del_formatted);
122 }
123
124 void ChatBuffer::deleteByAge(f32 maxAge)
125 {
126         u32 count = 0;
127         while (count < m_unformatted.size() && m_unformatted[count].age > maxAge)
128                 ++count;
129         deleteOldest(count);
130 }
131
132 u32 ChatBuffer::getColumns() const
133 {
134         return m_cols;
135 }
136
137 u32 ChatBuffer::getRows() const
138 {
139         return m_rows;
140 }
141
142 void ChatBuffer::reformat(u32 cols, u32 rows)
143 {
144         if (cols == 0 || rows == 0)
145         {
146                 // Clear formatted buffer
147                 m_cols = 0;
148                 m_rows = 0;
149                 m_scroll = 0;
150                 m_formatted.clear();
151         }
152         else if (cols != m_cols || rows != m_rows)
153         {
154                 // TODO: Avoid reformatting ALL lines (even inivisble ones)
155                 // each time the console size changes.
156
157                 // Find out the scroll position in *unformatted* lines
158                 u32 restore_scroll_unformatted = 0;
159                 u32 restore_scroll_formatted = 0;
160                 bool at_bottom = (m_scroll == getBottomScrollPos());
161                 if (!at_bottom)
162                 {
163                         for (s32 i = 0; i < m_scroll; ++i)
164                         {
165                                 if (m_formatted[i].first)
166                                         ++restore_scroll_unformatted;
167                         }
168                 }
169
170                 // If number of columns change, reformat everything
171                 if (cols != m_cols)
172                 {
173                         m_formatted.clear();
174                         for (u32 i = 0; i < m_unformatted.size(); ++i)
175                         {
176                                 if (i == restore_scroll_unformatted)
177                                         restore_scroll_formatted = m_formatted.size();
178                                 formatChatLine(m_unformatted[i], cols, m_formatted);
179                         }
180                 }
181
182                 // Update the console size
183                 m_cols = cols;
184                 m_rows = rows;
185
186                 // Restore the scroll position
187                 if (at_bottom)
188                 {
189                         scrollBottom();
190                 }
191                 else
192                 {
193                         scrollAbsolute(restore_scroll_formatted);
194                 }
195         }
196 }
197
198 const ChatFormattedLine& ChatBuffer::getFormattedLine(u32 row) const
199 {
200         s32 index = m_scroll + (s32) row;
201         if (index >= 0 && index < (s32) m_formatted.size())
202                 return m_formatted[index];
203         else
204                 return m_empty_formatted_line;
205 }
206
207 void ChatBuffer::scroll(s32 rows)
208 {
209         scrollAbsolute(m_scroll + rows);
210 }
211
212 void ChatBuffer::scrollAbsolute(s32 scroll)
213 {
214         s32 top = getTopScrollPos();
215         s32 bottom = getBottomScrollPos();
216
217         m_scroll = scroll;
218         if (m_scroll < top)
219                 m_scroll = top;
220         if (m_scroll > bottom)
221                 m_scroll = bottom;
222 }
223
224 void ChatBuffer::scrollBottom()
225 {
226         m_scroll = getBottomScrollPos();
227 }
228
229 void ChatBuffer::scrollTop()
230 {
231         m_scroll = getTopScrollPos();
232 }
233
234 u32 ChatBuffer::formatChatLine(const ChatLine& line, u32 cols,
235                 core::array<ChatFormattedLine>& destination) const
236 {
237         u32 num_added = 0;
238         core::array<ChatFormattedFragment> next_frags;
239         ChatFormattedLine next_line;
240         ChatFormattedFragment temp_frag;
241         u32 out_column = 0;
242         u32 in_pos = 0;
243         u32 hanging_indentation = 0;
244
245         // Format the sender name and produce fragments
246         if (!line.name.empty())
247         {
248                 temp_frag.text = L"<";
249                 temp_frag.column = 0;
250                 //temp_frag.bold = 0;
251                 next_frags.push_back(temp_frag);
252                 temp_frag.text = line.name;
253                 temp_frag.column = 0;
254                 //temp_frag.bold = 1;
255                 next_frags.push_back(temp_frag);
256                 temp_frag.text = L"> ";
257                 temp_frag.column = 0;
258                 //temp_frag.bold = 0;
259                 next_frags.push_back(temp_frag);
260         }
261
262         // Choose an indentation level
263         if (line.name.empty())
264         {
265                 // Server messages
266                 hanging_indentation = 0;
267         }
268         else if (line.name.size() + 3 <= cols/2)
269         {
270                 // Names shorter than about half the console width
271                 hanging_indentation = line.name.size() + 3;
272         }
273         else
274         {
275                 // Very long names
276                 hanging_indentation = 2;
277         }
278
279         next_line.first = true;
280         bool text_processing = false;
281
282         // Produce fragments and layout them into lines
283         while (!next_frags.empty() || in_pos < line.text.size())
284         {
285                 // Layout fragments into lines
286                 while (!next_frags.empty())
287                 {
288                         ChatFormattedFragment& frag = next_frags[0];
289                         if (frag.text.size() <= cols - out_column)
290                         {
291                                 // Fragment fits into current line
292                                 frag.column = out_column;
293                                 next_line.fragments.push_back(frag);
294                                 out_column += frag.text.size();
295                                 next_frags.erase(0, 1);
296                         }
297                         else
298                         {
299                                 // Fragment does not fit into current line
300                                 // So split it up
301                                 temp_frag.text = frag.text.substr(0, cols - out_column);
302                                 temp_frag.column = out_column;
303                                 //temp_frag.bold = frag.bold;
304                                 next_line.fragments.push_back(temp_frag);
305                                 frag.text = frag.text.substr(cols - out_column);
306                                 out_column = cols;
307                         }
308                         if (out_column == cols || text_processing)
309                         {
310                                 // End the current line
311                                 destination.push_back(next_line);
312                                 num_added++;
313                                 next_line.fragments.clear();
314                                 next_line.first = false;
315
316                                 out_column = text_processing ? hanging_indentation : 0;
317                         }
318                 }
319
320                 // Produce fragment
321                 if (in_pos < line.text.size())
322                 {
323                         u32 remaining_in_input = line.text.size() - in_pos;
324                         u32 remaining_in_output = cols - out_column;
325
326                         // Determine a fragment length <= the minimum of
327                         // remaining_in_{in,out}put. Try to end the fragment
328                         // on a word boundary.
329                         u32 frag_length = 1, space_pos = 0;
330                         while (frag_length < remaining_in_input &&
331                                         frag_length < remaining_in_output)
332                         {
333                                 if (isspace(line.text[in_pos + frag_length]))
334                                         space_pos = frag_length;
335                                 ++frag_length;
336                         }
337                         if (space_pos != 0 && frag_length < remaining_in_input)
338                                 frag_length = space_pos + 1;
339
340                         temp_frag.text = line.text.substr(in_pos, frag_length);
341                         temp_frag.column = 0;
342                         //temp_frag.bold = 0;
343                         next_frags.push_back(temp_frag);
344                         in_pos += frag_length;
345                         text_processing = true;
346                 }
347         }
348
349         // End the last line
350         if (num_added == 0 || !next_line.fragments.empty())
351         {
352                 destination.push_back(next_line);
353                 num_added++;
354         }
355
356         return num_added;
357 }
358
359 s32 ChatBuffer::getTopScrollPos() const
360 {
361         s32 formatted_count = (s32) m_formatted.size();
362         s32 rows = (s32) m_rows;
363         if (rows == 0)
364                 return 0;
365         else if (formatted_count <= rows)
366                 return formatted_count - rows;
367         else
368                 return 0;
369 }
370
371 s32 ChatBuffer::getBottomScrollPos() const
372 {
373         s32 formatted_count = (s32) m_formatted.size();
374         s32 rows = (s32) m_rows;
375         if (rows == 0)
376                 return 0;
377         else
378                 return formatted_count - rows;
379 }
380
381
382
383 ChatPrompt::ChatPrompt(std::wstring prompt, u32 history_limit):
384         m_prompt(prompt),
385         m_line(L""),
386         m_history(),
387         m_history_index(0),
388         m_history_limit(history_limit),
389         m_cols(0),
390         m_view(0),
391         m_cursor(0),
392         m_nick_completion_start(0),
393         m_nick_completion_end(0)
394 {
395 }
396
397 ChatPrompt::~ChatPrompt()
398 {
399 }
400
401 void ChatPrompt::input(wchar_t ch)
402 {
403         m_line.insert(m_cursor, 1, ch);
404         m_cursor++;
405         clampView();
406         m_nick_completion_start = 0;
407         m_nick_completion_end = 0;
408 }
409
410 std::wstring ChatPrompt::submit()
411 {
412         std::wstring line = m_line;
413         m_line.clear();
414         if (!line.empty())
415                 m_history.push_back(line);
416         if (m_history.size() > m_history_limit)
417                 m_history.erase(0);
418         m_history_index = m_history.size();
419         m_view = 0;
420         m_cursor = 0;
421         m_nick_completion_start = 0;
422         m_nick_completion_end = 0;
423         return line;
424 }
425
426 void ChatPrompt::clear()
427 {
428         m_line.clear();
429         m_view = 0;
430         m_cursor = 0;
431         m_nick_completion_start = 0;
432         m_nick_completion_end = 0;
433 }
434
435 void ChatPrompt::replace(std::wstring line)
436 {
437         m_line =  line;
438         m_view = m_cursor = line.size();
439         clampView();
440         m_nick_completion_start = 0;
441         m_nick_completion_end = 0;
442 }
443
444 void ChatPrompt::historyPrev()
445 {
446         if (m_history_index != 0)
447         {
448                 --m_history_index;
449                 replace(m_history[m_history_index]);
450         }
451 }
452
453 void ChatPrompt::historyNext()
454 {
455         if (m_history_index + 1 >= m_history.size())
456         {
457                 m_history_index = m_history.size();
458                 replace(L"");
459         }
460         else
461         {
462                 ++m_history_index;
463                 replace(m_history[m_history_index]);
464         }
465 }
466
467 void ChatPrompt::nickCompletion(const core::list<std::wstring>& names, bool backwards)
468 {
469         // Two cases:
470         // (a) m_nick_completion_start == m_nick_completion_end == 0
471         //     Then no previous nick completion is active.
472         //     Get the word around the cursor and replace with any nick
473         //     that has that word as a prefix.
474         // (b) else, continue a previous nick completion.
475         //     m_nick_completion_start..m_nick_completion_end are the
476         //     interval where the originally used prefix was. Cycle
477         //     through the list of completions of that prefix.
478         u32 prefix_start = m_nick_completion_start;
479         u32 prefix_end = m_nick_completion_end;
480         bool initial = (prefix_end == 0);
481         if (initial)
482         {
483                 // no previous nick completion is active
484                 prefix_start = prefix_end = m_cursor;
485                 while (prefix_start > 0 && !isspace(m_line[prefix_start-1]))
486                         --prefix_start;
487                 while (prefix_end < m_line.size() && !isspace(m_line[prefix_end]))
488                         ++prefix_end;
489                 if (prefix_start == prefix_end)
490                         return;
491         }
492         std::wstring prefix = m_line.substr(prefix_start, prefix_end - prefix_start);
493
494         // find all names that start with the selected prefix
495         core::array<std::wstring> completions;
496         for (core::list<std::wstring>::ConstIterator
497                         i = names.begin();
498                         i != names.end(); i++)
499         {
500                 if (str_starts_with(*i, prefix, true))
501                 {
502                         std::wstring completion = *i;
503                         if (prefix_start == 0)
504                                 completion += L":";
505                         completions.push_back(completion);
506                 }
507         }
508         if (completions.empty())
509                 return;
510
511         // find a replacement string and the word that will be replaced
512         u32 word_end = prefix_end;
513         u32 replacement_index = 0;
514         if (!initial)
515         {
516                 while (word_end < m_line.size() && !isspace(m_line[word_end]))
517                         ++word_end;
518                 std::wstring word = m_line.substr(prefix_start, word_end - prefix_start);
519
520                 // cycle through completions
521                 for (u32 i = 0; i < completions.size(); ++i)
522                 {
523                         if (str_equal(word, completions[i], true))
524                         {
525                                 if (backwards)
526                                         replacement_index = i + completions.size() - 1;
527                                 else
528                                         replacement_index = i + 1;
529                                 replacement_index %= completions.size();
530                                 break;
531                         }
532                 }
533         }
534         std::wstring replacement = completions[replacement_index] + L" ";
535         if (word_end < m_line.size() && isspace(word_end))
536                 ++word_end;
537
538         // replace existing word with replacement word,
539         // place the cursor at the end and record the completion prefix
540         m_line.replace(prefix_start, word_end - prefix_start, replacement);
541         m_cursor = prefix_start + replacement.size();
542         clampView();
543         m_nick_completion_start = prefix_start;
544         m_nick_completion_end = prefix_end;
545 }
546
547 void ChatPrompt::reformat(u32 cols)
548 {
549         if (cols <= m_prompt.size())
550         {
551                 m_cols = 0;
552                 m_view = m_cursor;
553         }
554         else
555         {
556                 s32 length = m_line.size();
557                 bool was_at_end = (m_view + m_cols >= length + 1);
558                 m_cols = cols - m_prompt.size();
559                 if (was_at_end)
560                         m_view = length;
561                 clampView();
562         }
563 }
564
565 std::wstring ChatPrompt::getVisiblePortion() const
566 {
567         return m_prompt + m_line.substr(m_view, m_cols);
568 }
569
570 s32 ChatPrompt::getVisibleCursorPosition() const
571 {
572         return m_cursor - m_view + m_prompt.size();
573 }
574
575 void ChatPrompt::cursorOperation(CursorOp op, CursorOpDir dir, CursorOpScope scope)
576 {
577         s32 old_cursor = m_cursor;
578         s32 new_cursor = m_cursor;
579
580         s32 length = m_line.size();
581         s32 increment = (dir == CURSOROP_DIR_RIGHT) ? 1 : -1;
582
583         if (scope == CURSOROP_SCOPE_CHARACTER)
584         {
585                 new_cursor += increment;
586         }
587         else if (scope == CURSOROP_SCOPE_WORD)
588         {
589                 if (increment > 0)
590                 {
591                         // skip one word to the right
592                         while (new_cursor < length && isspace(m_line[new_cursor]))
593                                 new_cursor++;
594                         while (new_cursor < length && !isspace(m_line[new_cursor]))
595                                 new_cursor++;
596                         while (new_cursor < length && isspace(m_line[new_cursor]))
597                                 new_cursor++;
598                 }
599                 else
600                 {
601                         // skip one word to the left
602                         while (new_cursor >= 1 && isspace(m_line[new_cursor - 1]))
603                                 new_cursor--;
604                         while (new_cursor >= 1 && !isspace(m_line[new_cursor - 1]))
605                                 new_cursor--;
606                 }
607         }
608         else if (scope == CURSOROP_SCOPE_LINE)
609         {
610                 new_cursor += increment * length;
611         }
612
613         new_cursor = MYMAX(MYMIN(new_cursor, length), 0);
614
615         if (op == CURSOROP_MOVE)
616         {
617                 m_cursor = new_cursor;
618         }
619         else if (op == CURSOROP_DELETE)
620         {
621                 if (new_cursor < old_cursor)
622                 {
623                         m_line.erase(new_cursor, old_cursor - new_cursor);
624                         m_cursor = new_cursor;
625                 }
626                 else if (new_cursor > old_cursor)
627                 {
628                         m_line.erase(old_cursor, new_cursor - old_cursor);
629                         m_cursor = old_cursor;
630                 }
631         }
632
633         clampView();
634
635         m_nick_completion_start = 0;
636         m_nick_completion_end = 0;
637 }
638
639 void ChatPrompt::clampView()
640 {
641         s32 length = m_line.size();
642         if (length + 1 <= m_cols)
643         {
644                 m_view = 0;
645         }
646         else
647         {
648                 m_view = MYMIN(m_view, length + 1 - m_cols);
649                 m_view = MYMIN(m_view, m_cursor);
650                 m_view = MYMAX(m_view, m_cursor - m_cols + 1);
651                 m_view = MYMAX(m_view, 0);
652         }
653 }
654
655
656
657 ChatBackend::ChatBackend():
658         m_console_buffer(500),
659         m_recent_buffer(6),
660         m_prompt(L"]", 500)
661 {
662 }
663
664 ChatBackend::~ChatBackend()
665 {
666 }
667
668 void ChatBackend::addMessage(std::wstring name, std::wstring text)
669 {
670         // Note: A message may consist of multiple lines, for example the MOTD.
671         WStrfnd fnd(text);
672         while (!fnd.atend())
673         {
674                 std::wstring line = fnd.next(L"\n");
675                 m_console_buffer.addLine(name, line);
676                 m_recent_buffer.addLine(name, line);
677         }
678 }
679
680 void ChatBackend::addUnparsedMessage(std::wstring message)
681 {
682         // TODO: Remove the need to parse chat messages client-side, by sending
683         // separate name and text fields in TOCLIENT_CHAT_MESSAGE.
684
685         if (message.size() >= 2 && message[0] == L'<')
686         {
687                 std::size_t closing = message.find_first_of(L'>', 1);
688                 if (closing != std::wstring::npos &&
689                                 closing + 2 <= message.size() &&
690                                 message[closing+1] == L' ')
691                 {
692                         std::wstring name = message.substr(1, closing - 1);
693                         std::wstring text = message.substr(closing + 2);
694                         addMessage(name, text);
695                         return;
696                 }
697         }
698
699         // Unable to parse, probably a server message.
700         addMessage(L"", message);
701 }
702
703 ChatBuffer& ChatBackend::getConsoleBuffer()
704 {
705         return m_console_buffer;
706 }
707
708 ChatBuffer& ChatBackend::getRecentBuffer()
709 {
710         return m_recent_buffer;
711 }
712
713 std::wstring ChatBackend::getRecentChat()
714 {
715         std::wostringstream stream;
716         for (u32 i = 0; i < m_recent_buffer.getLineCount(); ++i)
717         {
718                 const ChatLine& line = m_recent_buffer.getLine(i);
719                 if (i != 0)
720                         stream << L"\n";
721                 if (!line.name.empty())
722                         stream << L"<" << line.name << L"> ";
723                 stream << line.text;
724         }
725         return stream.str();
726 }
727
728 ChatPrompt& ChatBackend::getPrompt()
729 {
730         return m_prompt;
731 }
732
733 void ChatBackend::reformat(u32 cols, u32 rows)
734 {
735         m_console_buffer.reformat(cols, rows);
736
737         // no need to reformat m_recent_buffer, its formatted lines
738         // are not used
739
740         m_prompt.reformat(cols);
741 }
742
743 void ChatBackend::clearRecentChat()
744 {
745         m_recent_buffer.clear();
746 }
747
748 void ChatBackend::step(float dtime)
749 {
750         m_recent_buffer.step(dtime);
751         m_recent_buffer.deleteByAge(60.0);
752
753         // no need to age messages in anything but m_recent_buffer
754 }
755
756 void ChatBackend::scroll(s32 rows)
757 {
758         m_console_buffer.scroll(rows);
759 }
760
761 void ChatBackend::scrollPageDown()
762 {
763         m_console_buffer.scroll(m_console_buffer.getRows());
764 }
765
766 void ChatBackend::scrollPageUp()
767 {
768         m_console_buffer.scroll(-m_console_buffer.getRows());
769 }