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