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