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