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