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