3 Copyright (C) 2013 celeron55, Perttu Ahola <celeron55@gmail.com>
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.
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.
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.
28 #include "util/strfnd.h"
29 #include "util/string.h"
30 #include "util/numeric.h"
32 ChatBuffer::ChatBuffer(u32 scrollback):
33 m_scrollback(scrollback)
35 if (m_scrollback == 0)
37 m_empty_formatted_line.first = true;
39 m_cache_clickable_chat_weblinks = false;
40 // Curses mode cannot access g_settings here
41 if (g_settings != nullptr) {
42 m_cache_clickable_chat_weblinks = g_settings->getBool("clickable_chat_weblinks");
43 if (m_cache_clickable_chat_weblinks) {
44 std::string colorval = g_settings->get("chat_weblink_color");
45 parseColorString(colorval, m_cache_chat_weblink_color, false, 255);
46 m_cache_chat_weblink_color.setAlpha(255);
51 void ChatBuffer::addLine(const std::wstring &name, const std::wstring &text)
53 m_lines_modified = true;
55 ChatLine line(name, text);
56 m_unformatted.push_back(line);
59 // m_formatted is valid and must be kept valid
60 bool scrolled_at_bottom = (m_scroll == getBottomScrollPos());
61 u32 num_added = formatChatLine(line, m_cols, m_formatted);
62 if (scrolled_at_bottom)
63 m_scroll += num_added;
66 // Limit number of lines by m_scrollback
67 if (m_unformatted.size() > m_scrollback) {
68 deleteOldest(m_unformatted.size() - m_scrollback);
72 void ChatBuffer::clear()
74 m_unformatted.clear();
77 m_lines_modified = true;
80 u32 ChatBuffer::getLineCount() const
82 return m_unformatted.size();
85 const ChatLine& ChatBuffer::getLine(u32 index) const
87 assert(index < getLineCount()); // pre-condition
88 return m_unformatted[index];
91 void ChatBuffer::step(f32 dtime)
93 for (ChatLine &line : m_unformatted) {
98 void ChatBuffer::deleteOldest(u32 count)
100 bool at_bottom = (m_scroll == getBottomScrollPos());
102 u32 del_unformatted = 0;
103 u32 del_formatted = 0;
105 while (count > 0 && del_unformatted < m_unformatted.size()) {
108 // keep m_formatted in sync
109 if (del_formatted < m_formatted.size()) {
110 sanity_check(m_formatted[del_formatted].first);
112 while (del_formatted < m_formatted.size() &&
113 !m_formatted[del_formatted].first)
120 m_unformatted.erase(m_unformatted.begin(), m_unformatted.begin() + del_unformatted);
121 m_formatted.erase(m_formatted.begin(), m_formatted.begin() + del_formatted);
123 if (del_unformatted > 0)
124 m_lines_modified = true;
127 m_scroll = getBottomScrollPos();
129 scrollAbsolute(m_scroll - del_formatted);
132 void ChatBuffer::deleteByAge(f32 maxAge)
135 while (count < m_unformatted.size() && m_unformatted[count].age > maxAge)
140 u32 ChatBuffer::getRows() const
145 void ChatBuffer::scrollTop()
147 m_scroll = getTopScrollPos();
150 void ChatBuffer::reformat(u32 cols, u32 rows)
152 if (cols == 0 || rows == 0)
154 // Clear formatted buffer
160 else if (cols != m_cols || rows != m_rows)
162 // TODO: Avoid reformatting ALL lines (even invisible ones)
163 // each time the console size changes.
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());
171 for (s32 i = 0; i < m_scroll; ++i)
173 if (m_formatted[i].first)
174 ++restore_scroll_unformatted;
178 // If number of columns change, reformat everything
182 for (u32 i = 0; i < m_unformatted.size(); ++i)
184 if (i == restore_scroll_unformatted)
185 restore_scroll_formatted = m_formatted.size();
186 formatChatLine(m_unformatted[i], cols, m_formatted);
190 // Update the console size
194 // Restore the scroll position
201 scrollAbsolute(restore_scroll_formatted);
206 const ChatFormattedLine& ChatBuffer::getFormattedLine(u32 row) const
208 s32 index = m_scroll + (s32) row;
209 if (index >= 0 && index < (s32) m_formatted.size())
210 return m_formatted[index];
212 return m_empty_formatted_line;
215 void ChatBuffer::scroll(s32 rows)
217 scrollAbsolute(m_scroll + rows);
220 void ChatBuffer::scrollAbsolute(s32 scroll)
222 s32 top = getTopScrollPos();
223 s32 bottom = getBottomScrollPos();
228 if (m_scroll > bottom)
232 void ChatBuffer::scrollBottom()
234 m_scroll = getBottomScrollPos();
237 u32 ChatBuffer::formatChatLine(const ChatLine& line, u32 cols,
238 std::vector<ChatFormattedLine>& destination) const
241 std::vector<ChatFormattedFragment> next_frags;
242 ChatFormattedLine next_line;
243 ChatFormattedFragment temp_frag;
246 u32 hanging_indentation = 0;
248 // Format the sender name and produce fragments
249 if (!line.name.empty()) {
250 temp_frag.text = L"<";
251 temp_frag.column = 0;
252 //temp_frag.bold = 0;
253 next_frags.push_back(temp_frag);
254 temp_frag.text = line.name;
255 temp_frag.column = 0;
256 //temp_frag.bold = 1;
257 next_frags.push_back(temp_frag);
258 temp_frag.text = L"> ";
259 temp_frag.column = 0;
260 //temp_frag.bold = 0;
261 next_frags.push_back(temp_frag);
264 std::wstring name_sanitized = line.name.c_str();
266 // Choose an indentation level
267 if (line.name.empty()) {
269 hanging_indentation = 0;
270 } else if (name_sanitized.size() + 3 <= cols/2) {
271 // Names shorter than about half the console width
272 hanging_indentation = line.name.size() + 3;
275 hanging_indentation = 2;
277 //EnrichedString line_text(line.text);
279 next_line.first = true;
280 // Set/use forced newline after the last frag in each line
281 bool mark_newline = false;
283 // Produce fragments and layout them into lines
284 while (!next_frags.empty() || in_pos < line.text.size()) {
285 mark_newline = false; // now using this to USE line-end frag
287 // Layout fragments into lines
288 while (!next_frags.empty()) {
289 ChatFormattedFragment& frag = next_frags[0];
291 // Force newline after this frag, if marked
292 if (frag.column == INT_MAX)
295 if (frag.text.size() <= cols - out_column) {
296 // Fragment fits into current line
297 frag.column = out_column;
298 next_line.fragments.push_back(frag);
299 out_column += frag.text.size();
300 next_frags.erase(next_frags.begin());
302 // Fragment does not fit into current line
304 temp_frag.text = frag.text.substr(0, cols - out_column);
305 temp_frag.column = out_column;
306 temp_frag.weblink = frag.weblink;
308 next_line.fragments.push_back(temp_frag);
309 frag.text = frag.text.substr(cols - out_column);
314 if (out_column == cols || mark_newline) {
315 // End the current line
316 destination.push_back(next_line);
318 next_line.fragments.clear();
319 next_line.first = false;
321 out_column = hanging_indentation;
322 mark_newline = false;
326 // Produce fragment(s) for next formatted line
327 if (!(in_pos < line.text.size()))
330 const std::wstring &linestring = line.text.getString();
331 u32 remaining_in_output = cols - out_column;
332 size_t http_pos = std::wstring::npos;
333 mark_newline = false; // now using this to SET line-end frag
335 // Construct all frags for next output line
336 while (!mark_newline) {
337 // Determine a fragment length <= the minimum of
338 // remaining_in_{in,out}put. Try to end the fragment
339 // on a word boundary.
340 u32 frag_length = 0, space_pos = 0;
341 u32 remaining_in_input = line.text.size() - in_pos;
343 if (m_cache_clickable_chat_weblinks) {
344 // Note: unsigned(-1) on fail
345 http_pos = linestring.find(L"https://", in_pos);
346 if (http_pos == std::wstring::npos)
347 http_pos = linestring.find(L"http://", in_pos);
348 if (http_pos != std::wstring::npos)
352 while (frag_length < remaining_in_input &&
353 frag_length < remaining_in_output) {
354 if (iswspace(linestring[in_pos + frag_length]))
355 space_pos = frag_length;
359 if (http_pos >= remaining_in_output) {
360 // Http not in range, grab until space or EOL, halt as normal.
361 // Note this works because (http_pos = npos) is unsigned(-1)
364 } else if (http_pos == 0) {
365 // At http, grab ALL until FIRST whitespace or end marker. loop.
366 // If at end of string, next loop will be empty string to mark end of weblink.
368 frag_length = 6; // Frag is at least "http://"
370 // Chars to mark end of weblink
371 // TODO? replace this with a safer (slower) regex whitelist?
372 static const std::wstring delim_chars = L"\'\";,";
373 wchar_t tempchar = linestring[in_pos+frag_length];
374 while (frag_length < remaining_in_input &&
375 !iswspace(tempchar) &&
376 delim_chars.find(tempchar) == std::wstring::npos) {
378 tempchar = linestring[in_pos+frag_length];
381 space_pos = frag_length - 1;
382 // This frag may need to be force-split. That's ok, urls aren't "words"
383 if (frag_length >= remaining_in_output) {
387 // Http in range, grab until http, loop
389 space_pos = http_pos - 1;
390 frag_length = http_pos;
393 // Include trailing space in current frag
394 if (space_pos != 0 && frag_length < remaining_in_input)
395 frag_length = space_pos + 1;
397 temp_frag.text = line.text.substr(in_pos, frag_length);
398 // A hack so this frag remembers mark_newline for the layout phase
399 temp_frag.column = mark_newline ? INT_MAX : 0;
402 // Discard color stuff from the source frag
403 temp_frag.text = EnrichedString(temp_frag.text.getString());
404 temp_frag.text.setDefaultColor(m_cache_chat_weblink_color);
405 // Set weblink in the frag meta
406 temp_frag.weblink = wide_to_utf8(temp_frag.text.getString());
408 temp_frag.weblink.clear();
410 next_frags.push_back(temp_frag);
411 in_pos += frag_length;
412 remaining_in_output -= std::min(frag_length, remaining_in_output);
417 if (num_added == 0 || !next_line.fragments.empty()) {
418 destination.push_back(next_line);
425 s32 ChatBuffer::getTopScrollPos() const
427 s32 formatted_count = (s32) m_formatted.size();
428 s32 rows = (s32) m_rows;
432 if (formatted_count <= rows)
433 return formatted_count - rows;
438 s32 ChatBuffer::getBottomScrollPos() const
440 s32 formatted_count = (s32) m_formatted.size();
441 s32 rows = (s32) m_rows;
445 return formatted_count - rows;
448 void ChatBuffer::resize(u32 scrollback)
450 m_scrollback = scrollback;
451 if (m_unformatted.size() > m_scrollback)
452 deleteOldest(m_unformatted.size() - m_scrollback);
456 ChatPrompt::ChatPrompt(const std::wstring &prompt, u32 history_limit):
458 m_history_limit(history_limit)
462 void ChatPrompt::input(wchar_t ch)
464 m_line.insert(m_cursor, 1, ch);
467 m_nick_completion_start = 0;
468 m_nick_completion_end = 0;
471 void ChatPrompt::input(const std::wstring &str)
473 m_line.insert(m_cursor, str);
474 m_cursor += str.size();
476 m_nick_completion_start = 0;
477 m_nick_completion_end = 0;
480 void ChatPrompt::addToHistory(const std::wstring &line)
483 (m_history.size() == 0 || m_history.back() != line)) {
484 // Remove all duplicates
485 m_history.erase(std::remove(m_history.begin(), m_history.end(),
486 line), m_history.end());
488 m_history.push_back(line);
490 if (m_history.size() > m_history_limit)
491 m_history.erase(m_history.begin());
492 m_history_index = m_history.size();
495 void ChatPrompt::clear()
500 m_nick_completion_start = 0;
501 m_nick_completion_end = 0;
504 std::wstring ChatPrompt::replace(const std::wstring &line)
506 std::wstring old_line = m_line;
508 m_view = m_cursor = line.size();
510 m_nick_completion_start = 0;
511 m_nick_completion_end = 0;
515 void ChatPrompt::historyPrev()
517 if (m_history_index != 0)
520 replace(m_history[m_history_index]);
524 void ChatPrompt::historyNext()
526 if (m_history_index + 1 >= m_history.size())
528 m_history_index = m_history.size();
534 replace(m_history[m_history_index]);
538 void ChatPrompt::nickCompletion(const std::list<std::string>& names, bool backwards)
541 // (a) m_nick_completion_start == m_nick_completion_end == 0
542 // Then no previous nick completion is active.
543 // Get the word around the cursor and replace with any nick
544 // that has that word as a prefix.
545 // (b) else, continue a previous nick completion.
546 // m_nick_completion_start..m_nick_completion_end are the
547 // interval where the originally used prefix was. Cycle
548 // through the list of completions of that prefix.
549 u32 prefix_start = m_nick_completion_start;
550 u32 prefix_end = m_nick_completion_end;
551 bool initial = (prefix_end == 0);
554 // no previous nick completion is active
555 prefix_start = prefix_end = m_cursor;
556 while (prefix_start > 0 && !iswspace(m_line[prefix_start-1]))
558 while (prefix_end < m_line.size() && !iswspace(m_line[prefix_end]))
560 if (prefix_start == prefix_end)
563 std::wstring prefix = m_line.substr(prefix_start, prefix_end - prefix_start);
565 // find all names that start with the selected prefix
566 std::vector<std::wstring> completions;
567 for (const std::string &name : names) {
568 std::wstring completion = utf8_to_wide(name);
569 if (str_starts_with(completion, prefix, true)) {
570 if (prefix_start == 0)
572 completions.push_back(completion);
576 if (completions.empty())
579 // find a replacement string and the word that will be replaced
580 u32 word_end = prefix_end;
581 u32 replacement_index = 0;
584 while (word_end < m_line.size() && !iswspace(m_line[word_end]))
586 std::wstring word = m_line.substr(prefix_start, word_end - prefix_start);
588 // cycle through completions
589 for (u32 i = 0; i < completions.size(); ++i)
591 if (str_equal(word, completions[i], true))
594 replacement_index = i + completions.size() - 1;
596 replacement_index = i + 1;
597 replacement_index %= completions.size();
602 std::wstring replacement = completions[replacement_index];
603 if (word_end < m_line.size() && iswspace(m_line[word_end]))
606 // replace existing word with replacement word,
607 // place the cursor at the end and record the completion prefix
608 m_line.replace(prefix_start, word_end - prefix_start, replacement);
609 m_cursor = prefix_start + replacement.size();
611 m_nick_completion_start = prefix_start;
612 m_nick_completion_end = prefix_end;
615 void ChatPrompt::reformat(u32 cols)
617 if (cols <= m_prompt.size())
624 s32 length = m_line.size();
625 bool was_at_end = (m_view + m_cols >= length + 1);
626 m_cols = cols - m_prompt.size();
633 std::wstring ChatPrompt::getVisiblePortion() const
635 return m_prompt + m_line.substr(m_view, m_cols);
638 s32 ChatPrompt::getVisibleCursorPosition() const
640 return m_cursor - m_view + m_prompt.size();
643 void ChatPrompt::cursorOperation(CursorOp op, CursorOpDir dir, CursorOpScope scope)
645 s32 old_cursor = m_cursor;
646 s32 new_cursor = m_cursor;
648 s32 length = m_line.size();
649 s32 increment = (dir == CURSOROP_DIR_RIGHT) ? 1 : -1;
652 case CURSOROP_SCOPE_CHARACTER:
653 new_cursor += increment;
655 case CURSOROP_SCOPE_WORD:
656 if (dir == CURSOROP_DIR_RIGHT) {
657 // skip one word to the right
658 while (new_cursor < length && iswspace(m_line[new_cursor]))
660 while (new_cursor < length && !iswspace(m_line[new_cursor]))
662 while (new_cursor < length && iswspace(m_line[new_cursor]))
665 // skip one word to the left
666 while (new_cursor >= 1 && iswspace(m_line[new_cursor - 1]))
668 while (new_cursor >= 1 && !iswspace(m_line[new_cursor - 1]))
672 case CURSOROP_SCOPE_LINE:
673 new_cursor += increment * length;
675 case CURSOROP_SCOPE_SELECTION:
679 new_cursor = MYMAX(MYMIN(new_cursor, length), 0);
683 m_cursor = new_cursor;
686 case CURSOROP_DELETE:
687 if (m_cursor_len > 0) { // Delete selected text first
688 m_line.erase(m_cursor, m_cursor_len);
690 m_cursor = MYMIN(new_cursor, old_cursor);
691 m_line.erase(m_cursor, abs(new_cursor - old_cursor));
695 case CURSOROP_SELECT:
696 if (scope == CURSOROP_SCOPE_LINE) {
698 m_cursor_len = length;
700 m_cursor = MYMIN(new_cursor, old_cursor);
701 m_cursor_len += abs(new_cursor - old_cursor);
702 m_cursor_len = MYMIN(m_cursor_len, length - m_cursor);
709 m_nick_completion_start = 0;
710 m_nick_completion_end = 0;
713 void ChatPrompt::clampView()
715 s32 length = m_line.size();
716 if (length + 1 <= m_cols)
722 m_view = MYMIN(m_view, length + 1 - m_cols);
723 m_view = MYMIN(m_view, m_cursor);
724 m_view = MYMAX(m_view, m_cursor - m_cols + 1);
725 m_view = MYMAX(m_view, 0);
731 ChatBackend::ChatBackend():
732 m_console_buffer(500),
738 void ChatBackend::addMessage(const std::wstring &name, std::wstring text)
740 // Note: A message may consist of multiple lines, for example the MOTD.
741 text = translate_string(text);
743 while (!fnd.at_end())
745 std::wstring line = fnd.next(L"\n");
746 m_console_buffer.addLine(name, line);
747 m_recent_buffer.addLine(name, line);
751 void ChatBackend::addUnparsedMessage(std::wstring message)
753 // TODO: Remove the need to parse chat messages client-side, by sending
754 // separate name and text fields in TOCLIENT_CHAT_MESSAGE.
756 if (message.size() >= 2 && message[0] == L'<')
758 std::size_t closing = message.find_first_of(L'>', 1);
759 if (closing != std::wstring::npos &&
760 closing + 2 <= message.size() &&
761 message[closing+1] == L' ')
763 std::wstring name = message.substr(1, closing - 1);
764 std::wstring text = message.substr(closing + 2);
765 addMessage(name, text);
770 // Unable to parse, probably a server message.
771 addMessage(L"", message);
774 ChatBuffer& ChatBackend::getConsoleBuffer()
776 return m_console_buffer;
779 ChatBuffer& ChatBackend::getRecentBuffer()
781 return m_recent_buffer;
784 EnrichedString ChatBackend::getRecentChat() const
786 EnrichedString result;
787 for (u32 i = 0; i < m_recent_buffer.getLineCount(); ++i) {
788 const ChatLine& line = m_recent_buffer.getLine(i);
791 if (!line.name.empty()) {
801 ChatPrompt& ChatBackend::getPrompt()
806 void ChatBackend::reformat(u32 cols, u32 rows)
808 m_console_buffer.reformat(cols, rows);
810 // no need to reformat m_recent_buffer, its formatted lines
813 m_prompt.reformat(cols);
816 void ChatBackend::clearRecentChat()
818 m_recent_buffer.clear();
822 void ChatBackend::applySettings()
824 u32 recent_lines = g_settings->getU32("recent_chat_messages");
825 recent_lines = rangelim(recent_lines, 2, 20);
826 m_recent_buffer.resize(recent_lines);
829 void ChatBackend::step(float dtime)
831 m_recent_buffer.step(dtime);
832 m_recent_buffer.deleteByAge(60.0);
834 // no need to age messages in anything but m_recent_buffer
837 void ChatBackend::scroll(s32 rows)
839 m_console_buffer.scroll(rows);
842 void ChatBackend::scrollPageDown()
844 m_console_buffer.scroll(m_console_buffer.getRows());
847 void ChatBackend::scrollPageUp()
849 m_console_buffer.scroll(-(s32)m_console_buffer.getRows());