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