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