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