#include "chat.h"
#include <algorithm>
#include "debug.h"
#include "gettext.h"
#include "player.h"
#include "porting.h"
#include "settings.h"
#include "util/strfnd.h"
#include "util/string.h"
#include "util/numeric.h"
ChatBuffer::ChatBuffer(u32 scrollback):
m_scrollback(scrollback)
{
if (m_scrollback == 0)
m_scrollback = 1;
m_empty_formatted_line.first = true;
m_cache_clickable_chat_weblinks = false;
if (g_settings != nullptr) {
m_cache_clickable_chat_weblinks = g_settings->getBool("clickable_chat_weblinks");
if (m_cache_clickable_chat_weblinks) {
std::string colorval = g_settings->get("chat_weblink_color");
parseColorString(colorval, m_cache_chat_weblink_color, false, 255);
m_cache_chat_weblink_color.setAlpha(255);
}
}
}
void ChatBuffer::addLine(const EnrichedString &name, const EnrichedString &text)
{
m_lines_modified = true;
ChatLine line(name, text);
m_unformatted.push_back(line);
if (m_rows > 0) {
bool scrolled_at_bottom = (m_scroll == getBottomScrollPos());
u32 num_added = formatChatLine(line, m_cols, m_formatted);
if (scrolled_at_bottom)
m_scroll += num_added;
}
if (m_unformatted.size() > m_scrollback) {
deleteOldest(m_unformatted.size() - m_scrollback);
}
}
void ChatBuffer::clear()
{
m_unformatted.clear();
m_formatted.clear();
m_scroll = 0;
m_lines_modified = true;
}
u32 ChatBuffer::getLineCount() const
{
return m_unformatted.size();
}
const ChatLine& ChatBuffer::getLine(u32 index) const
{
assert(index < getLineCount());
return m_unformatted[index];
}
void ChatBuffer::step(f32 dtime)
{
for (ChatLine &line : m_unformatted) {
line.age += dtime;
}
}
void ChatBuffer::deleteOldest(u32 count)
{
bool at_bottom = (m_scroll == getBottomScrollPos());
u32 del_unformatted = 0;
u32 del_formatted = 0;
while (count > 0 && del_unformatted < m_unformatted.size()) {
++del_unformatted;
if (del_formatted < m_formatted.size()) {
sanity_check(m_formatted[del_formatted].first);
++del_formatted;
while (del_formatted < m_formatted.size() &&
!m_formatted[del_formatted].first)
++del_formatted;
}
--count;
}
m_unformatted.erase(m_unformatted.begin(), m_unformatted.begin() + del_unformatted);
m_formatted.erase(m_formatted.begin(), m_formatted.begin() + del_formatted);
if (del_unformatted > 0)
m_lines_modified = true;
if (at_bottom)
m_scroll = getBottomScrollPos();
else
scrollAbsolute(m_scroll - del_formatted);
}
void ChatBuffer::deleteByAge(f32 maxAge)
{
u32 count = 0;
while (count < m_unformatted.size() && m_unformatted[count].age > maxAge)
++count;
deleteOldest(count);
}
u32 ChatBuffer::getRows() const
{
return m_rows;
}
void ChatBuffer::reformat(u32 cols, u32 rows)
{
if (cols == 0 || rows == 0)
{
m_cols = 0;
m_rows = 0;
m_scroll = 0;
m_formatted.clear();
}
else if (cols != m_cols || rows != m_rows)
{
u32 restore_scroll_unformatted = 0;
u32 restore_scroll_formatted = 0;
bool at_bottom = (m_scroll == getBottomScrollPos());
if (!at_bottom)
{
for (s32 i = 0; i < m_scroll; ++i)
{
if (m_formatted[i].first)
++restore_scroll_unformatted;
}
}
if (cols != m_cols)
{
m_formatted.clear();
for (u32 i = 0; i < m_unformatted.size(); ++i)
{
if (i == restore_scroll_unformatted)
restore_scroll_formatted = m_formatted.size();
formatChatLine(m_unformatted[i], cols, m_formatted);
}
}
m_cols = cols;
m_rows = rows;
if (at_bottom)
{
scrollBottom();
}
else
{
scrollAbsolute(restore_scroll_formatted);
}
}
}
const ChatFormattedLine& ChatBuffer::getFormattedLine(u32 row) const
{
s32 index = m_scroll + (s32) row;
if (index >= 0 && index < (s32) m_formatted.size())
return m_formatted[index];
return m_empty_formatted_line;
}
void ChatBuffer::scroll(s32 rows)
{
scrollAbsolute(m_scroll + rows);
}
void ChatBuffer::scrollAbsolute(s32 scroll)
{
s32 top = getTopScrollPos();
s32 bottom = getBottomScrollPos();
m_scroll = scroll;
if (m_scroll < top)
m_scroll = top;
if (m_scroll > bottom)
m_scroll = bottom;
}
void ChatBuffer::scrollBottom()
{
m_scroll = getBottomScrollPos();
}
u32 ChatBuffer::formatChatLine(const ChatLine &line, u32 cols,
std::vector<ChatFormattedLine> &destination) const
{
u32 num_added = 0;
std::vector<ChatFormattedFragment> next_frags;
ChatFormattedLine next_line;
ChatFormattedFragment temp_frag;
u32 out_column = 0;
u32 in_pos = 0;
u32 hanging_indentation = 0;
if (!line.name.empty()) {
temp_frag.text = L"<";
temp_frag.column = 0;
next_frags.push_back(temp_frag);
temp_frag.text = line.name;
temp_frag.column = 0;
next_frags.push_back(temp_frag);
temp_frag.text = L"> ";
temp_frag.column = 0;
next_frags.push_back(temp_frag);
}
std::wstring name_sanitized = line.name.c_str();
if (line.name.empty()) {
hanging_indentation = 0;
} else if (name_sanitized.size() + 3 <= cols/2) {
hanging_indentation = line.name.size() + 3;
} else {
hanging_indentation = 2;
}
if (hanging_indentation >= cols)
return 0;
next_line.first = true;
bool mark_newline = false;
while (!next_frags.empty() || in_pos < line.text.size()) {
mark_newline = false;
while (!next_frags.empty()) {
ChatFormattedFragment& frag = next_frags[0];
if (frag.column == INT_MAX)
mark_newline = true;
if (frag.text.size() <= cols - out_column) {
frag.column = out_column;
next_line.fragments.push_back(frag);
out_column += frag.text.size();
next_frags.erase(next_frags.begin());
} else {
temp_frag.text = frag.text.substr(0, cols - out_column);
temp_frag.column = out_column;
temp_frag.weblink = frag.weblink;
next_line.fragments.push_back(temp_frag);
frag.text = frag.text.substr(cols - out_column);
frag.column = 0;
out_column = cols;
}
if (out_column == cols || mark_newline) {
destination.push_back(next_line);
num_added++;
next_line.fragments.clear();
next_line.first = false;
out_column = hanging_indentation;
mark_newline = false;
}
}
if (!(in_pos < line.text.size()))
continue;
const std::wstring &linestring = line.text.getString();
u32 remaining_in_output = cols - out_column;
size_t http_pos = std::wstring::npos;
mark_newline = false;
while (!mark_newline) {
u32 frag_length = 0, space_pos = 0;
u32 remaining_in_input = line.text.size() - in_pos;
if (m_cache_clickable_chat_weblinks) {
http_pos = linestring.find(L"https://", in_pos);
if (http_pos == std::wstring::npos)
http_pos = linestring.find(L"http://", in_pos);
if (http_pos != std::wstring::npos)
http_pos -= in_pos;
}
while (frag_length < remaining_in_input &&
frag_length < remaining_in_output) {
if (iswspace(linestring[in_pos + frag_length]))
space_pos = frag_length;
++frag_length;
}
if (http_pos >= remaining_in_output) {
mark_newline = true;
} else if (http_pos == 0) {
frag_length = 6;
static const std::wstring delim_chars = L"\'\";";
wchar_t tempchar = linestring[in_pos+frag_length];
while (frag_length < remaining_in_input &&
!iswspace(tempchar) &&
delim_chars.find(tempchar) == std::wstring::npos) {
++frag_length;
tempchar = linestring[in_pos+frag_length];
}
static const std::wstring tailing_chars = L",.";
tempchar = linestring[in_pos+frag_length - 1];
if (tailing_chars.find(tempchar) != std::wstring::npos) {
frag_length--;
}
space_pos = frag_length - 1;
if (frag_length >= remaining_in_output) {
mark_newline = true;
}
} else {
space_pos = http_pos - 1;
frag_length = http_pos;
}
if (space_pos != 0 && frag_length < remaining_in_input)
frag_length = space_pos + 1;
temp_frag.text = line.text.substr(in_pos, frag_length);
temp_frag.column = mark_newline ? INT_MAX : 0;
if (http_pos == 0) {
temp_frag.text = EnrichedString(temp_frag.text.getString());
temp_frag.text.setDefaultColor(m_cache_chat_weblink_color);
temp_frag.weblink = wide_to_utf8(temp_frag.text.getString());
} else {
temp_frag.weblink.clear();
}
next_frags.push_back(temp_frag);
in_pos += frag_length;
remaining_in_output -= std::min(frag_length, remaining_in_output);
}
}
if (num_added == 0 || !next_line.fragments.empty()) {
destination.push_back(next_line);
num_added++;
}
return num_added;
}
s32 ChatBuffer::getTopScrollPos() const
{
s32 formatted_count = (s32) m_formatted.size();
s32 rows = (s32) m_rows;
if (rows == 0)
return 0;
if (formatted_count <= rows)
return formatted_count - rows;
return 0;
}
s32 ChatBuffer::getBottomScrollPos() const
{
s32 formatted_count = (s32) m_formatted.size();
s32 rows = (s32) m_rows;
if (rows == 0)
return 0;
return formatted_count - rows;
}
void ChatBuffer::resize(u32 scrollback)
{
m_scrollback = scrollback;
if (m_unformatted.size() > m_scrollback)
deleteOldest(m_unformatted.size() - m_scrollback);
}
ChatPrompt::ChatPrompt(const std::wstring &prompt, u32 history_limit):
m_prompt(prompt),
m_history_limit(history_limit)
{
}
const std::wstring &ChatPrompt::getLineRef() const
{
return m_history_index >= m_history.size() ? m_line : m_history[m_history_index].line;
}
std::wstring &ChatPrompt::makeLineRef()
{
if (m_history_index >= m_history.size()) {
return m_line;
} else {
if (!m_history[m_history_index].saved)
m_history[m_history_index].saved = m_history[m_history_index].line;
return m_history[m_history_index].line;
}
}
bool ChatPrompt::HistoryEntry::operator==(const ChatPrompt::HistoryEntry &other)
{
if (line != other.line)
return false;
if (saved == other.saved)
return true;
if ((!saved || saved == line) && (!other.saved || other.saved == other.line))
return true;
return false;
}
void ChatPrompt::input(wchar_t ch)
{
makeLineRef().insert(m_cursor, 1, ch);
m_cursor++;
clampView();
}
void ChatPrompt::input(const std::wstring &str)
{
makeLineRef().insert(m_cursor, str);
m_cursor += str.size();
clampView();
}
void ChatPrompt::addToHistory(const std::wstring &line)
{
std::wstring old_line = getLine();
if (m_history_index < m_history.size()) {
auto entry = m_history.begin() + m_history_index;
if (entry->saved && entry->line == line) {
entry->line = *entry->saved;
entry->saved = std::nullopt;
auto dup_before = std::find(m_history.begin(), entry, *entry);
if (dup_before != entry)
m_history.erase(dup_before);
else if (std::find(entry + 1, m_history.end(), *entry) != m_history.end())
m_history.erase(entry);
}
}
if (!line.empty() &&
(m_history.size() == 0 || m_history.back().line != line)) {
HistoryEntry entry(line);
m_history.erase(std::remove(m_history.begin(), m_history.end(), entry),
m_history.end());
m_history.push_back(std::move(entry));
}
if (m_history.size() > m_history_limit)
m_history.erase(m_history.begin());
m_history_index = m_history.size();
m_line = std::move(old_line);
}
void ChatPrompt::clear()
{
makeLineRef().clear();
m_view = 0;
m_cursor = 0;
}
std::wstring ChatPrompt::replace(const std::wstring &line)
{
std::wstring old_line = getLine();
makeLineRef() = line;
m_view = m_cursor = line.size();
clampView();
return old_line;
}
void ChatPrompt::historyPrev()
{
if (m_history_index != 0) {
--m_history_index;
m_view = m_cursor = getLineRef().size();
clampView();
}
}
void ChatPrompt::historyNext()
{
if (m_history_index < m_history.size()) {
m_history_index++;
m_view = m_cursor = getLineRef().size();
clampView();
}
}
void ChatPrompt::nickCompletion(const std::set<std::string> &names)
{
const std::wstring_view line(getLineRef());
u32 prefix_start = m_cursor;
u32 prefix_end = m_cursor;
auto is_playername_char = [](wchar_t c) -> bool {
if (c > 0x7F)
return false;
constexpr std::string_view allowed_chars(PLAYERNAME_ALLOWED_CHARS);
return allowed_chars.find(static_cast<char>(c)) != std::string_view::npos;
};
{
while (prefix_start > 0 && is_playername_char(line[prefix_start - 1]))
--prefix_start;
while (prefix_end < line.size() && is_playername_char(line[prefix_end]))
++prefix_end;
if (prefix_start == prefix_end)
return;
}
auto prefix = line.substr(prefix_start, prefix_end - prefix_start);
std::vector<std::wstring> completions;
std::wstring shortest;
for (const std::string &name : names) {
std::wstring completion = utf8_to_wide(name);
if (!str_starts_with(completion, prefix, true))
continue;
completions.push_back(completion);
if (shortest.empty()) {
shortest = completion;
} else {
bool did_cut = false;
for (size_t i = prefix.size(); i < std::min(shortest.size(), completion.size()); ++i) {
if (my_tolower(shortest[i]) != my_tolower(completion[i])) {
shortest.resize(i);
did_cut = true;
break;
}
}
if (!did_cut && completion.size() < shortest.size()) {
shortest = completion;
}
}
}
if (completions.empty())
return;
if (completions.size() > 1 && prefix.size() == shortest.size()) {
std::wstring options = wstrgettext("Player names: ");
for (auto v : completions)
options.append(v).append(L", ");
options.resize(options.size() - 2);
if (m_chat_buffer) {
m_chat_buffer->addLine(
EnrichedString(L""),
EnrichedString(options, video::SColor(255, 200, 200, 200))
);
} else {
rawstream << wide_to_utf8(options) << std::endl;
}
return;
}
if (completions.size() == 1) {
if (prefix.size() == line.size())
shortest.append(L": ");
else if (prefix_end == line.size())
shortest.append(L" ");
}
makeLineRef().replace(prefix_start, prefix_end - prefix_start, shortest);
m_cursor = prefix_start + shortest.size();
m_cursor_len = 0;
clampView();
}
void ChatPrompt::reformat(u32 cols)
{
if (cols <= m_prompt.size())
{
m_cols = 0;
m_view = m_cursor;
}
else
{
s32 length = getLineRef().size();
bool was_at_end = (m_view + m_cols >= length + 1);
m_cols = cols - m_prompt.size();
if (was_at_end)
m_view = length;
clampView();
}
}
std::wstring ChatPrompt::getVisiblePortion() const
{
const std::wstring &line_ref = getLineRef();
if ((size_t)m_view >= line_ref.size())
return m_prompt;
else
return m_prompt + line_ref.substr(m_view, m_cols);
}
s32 ChatPrompt::getVisibleCursorPosition() const
{
return m_cursor - m_view + m_prompt.size();
}
void ChatPrompt::cursorOperation(CursorOp op, CursorOpDir dir, CursorOpScope scope)
{
s32 old_cursor = m_cursor;
s32 new_cursor = m_cursor;
const std::wstring &line = getLineRef();
s32 length = line.size();
s32 increment = (dir == CURSOROP_DIR_RIGHT) ? 1 : -1;
switch (scope) {
case CURSOROP_SCOPE_CHARACTER:
new_cursor += increment;
break;
case CURSOROP_SCOPE_WORD:
if (dir == CURSOROP_DIR_RIGHT) {
while (new_cursor < length && iswspace(line[new_cursor]))
new_cursor++;
while (new_cursor < length && !iswspace(line[new_cursor]))
new_cursor++;
while (new_cursor < length && iswspace(line[new_cursor]))
new_cursor++;
} else {
while (new_cursor >= 1 && iswspace(line[new_cursor - 1]))
new_cursor--;
while (new_cursor >= 1 && !iswspace(line[new_cursor - 1]))
new_cursor--;
}
break;
case CURSOROP_SCOPE_LINE:
new_cursor += increment * length;
break;
case CURSOROP_SCOPE_SELECTION:
break;
}
new_cursor = MYMAX(MYMIN(new_cursor, length), 0);
switch (op) {
case CURSOROP_MOVE:
m_cursor = new_cursor;
m_cursor_len = 0;
break;
case CURSOROP_DELETE:
if (m_cursor_len > 0) {
makeLineRef().erase(m_cursor, m_cursor_len);
} else {
m_cursor = MYMIN(new_cursor, old_cursor);
makeLineRef().erase(m_cursor, abs(new_cursor - old_cursor));
}
m_cursor_len = 0;
break;
case CURSOROP_SELECT:
if (scope == CURSOROP_SCOPE_LINE) {
m_cursor = 0;
m_cursor_len = length;
} else {
m_cursor = MYMIN(new_cursor, old_cursor);
m_cursor_len += abs(new_cursor - old_cursor);
m_cursor_len = MYMIN(m_cursor_len, length - m_cursor);
}
break;
}
clampView();
}
void ChatPrompt::clampView()
{
s32 length = getLineRef().size();
if (length + 1 <= m_cols)
{
m_view = 0;
}
else
{
m_view = MYMIN(m_view, length + 1 - m_cols);
m_view = MYMIN(m_view, m_cursor);
m_view = MYMAX(m_view, m_cursor - m_cols + 1);
m_view = MYMAX(m_view, 0);
}
}
ChatBackend::ChatBackend():
m_console_buffer(1500),
m_recent_buffer(6),
m_prompt(L"]", 1500)
{
m_prompt.setChatBuffer(&m_console_buffer);
}
void ChatBackend::addMessage(const std::wstring &name, std::wstring text)
{
EnrichedString ename(name);
EnrichedString etext(text);
size_t str_pos = 0;
while (str_pos < etext.size()) {
EnrichedString line = etext.getNextLine(&str_pos);
m_console_buffer.addLine(ename, line);
m_recent_buffer.addLine(ename, line);
}
}
void ChatBackend::addUnparsedMessage(std::wstring message)
{
if (message.size() >= 2 && message[0] == L'<')
{
std::size_t closing = message.find_first_of(L'>', 1);
if (closing != std::wstring::npos &&
closing + 2 <= message.size() &&
message[closing+1] == L' ')
{
std::wstring name = message.substr(1, closing - 1);
std::wstring text = message.substr(closing + 2);
addMessage(name, text);
return;
}
}
addMessage(L"", message);
}
ChatBuffer& ChatBackend::getConsoleBuffer()
{
return m_console_buffer;
}
ChatBuffer& ChatBackend::getRecentBuffer()
{
return m_recent_buffer;
}
EnrichedString ChatBackend::getRecentChat() const
{
EnrichedString result;
for (u32 i = 0; i < m_recent_buffer.getLineCount(); ++i) {
const ChatLine& line = m_recent_buffer.getLine(i);
if (i != 0)
result += L"\n";
if (!line.name.empty()) {
result += L"<";
result += line.name;
result += L"> ";
}
result += line.text;
}
return result;
}
ChatPrompt& ChatBackend::getPrompt()
{
return m_prompt;
}
void ChatBackend::reformat(u32 cols, u32 rows)
{
m_console_buffer.reformat(cols, rows);
m_prompt.reformat(cols);
}
void ChatBackend::clearRecentChat()
{
m_recent_buffer.clear();
}
void ChatBackend::applySettings()
{
u32 recent_lines = g_settings->getU32("recent_chat_messages");
recent_lines = rangelim(recent_lines, 2, 20);
m_recent_buffer.resize(recent_lines);
}
void ChatBackend::step(float dtime)
{
m_recent_buffer.step(dtime);
m_recent_buffer.deleteByAge(60.0);
}
void ChatBackend::scroll(s32 rows)
{
m_console_buffer.scroll(rows);
}
void ChatBackend::scrollPageDown()
{
m_console_buffer.scroll(m_console_buffer.getRows());
}
void ChatBackend::scrollPageUp()
{
m_console_buffer.scroll(-(s32)m_console_buffer.getRows());
}