]> git.lizzy.rs Git - dragonfireclient.git/commitdiff
Make chat web links clickable (#11092)
authorpecksin <78765996+pecksin@users.noreply.github.com>
Sun, 20 Jun 2021 15:20:24 +0000 (11:20 -0400)
committerGitHub <noreply@github.com>
Sun, 20 Jun 2021 15:20:24 +0000 (17:20 +0200)
If enabled in minetest.conf, provides colored, clickable (middle-mouse or ctrl-left-mouse) weblinks in chat output, to open the OS' default web browser.

builtin/settingtypes.txt
minetest.conf.example
po/minetest.pot
src/chat.cpp
src/chat.h
src/defaultsettings.cpp
src/gui/guiChatConsole.cpp
src/gui/guiChatConsole.h

index 57857cabba984829a81b7780e28a18263000cbe6..fd7d8b9b925cb083129b939ccf4f5455151e1307 100644 (file)
@@ -973,6 +973,12 @@ mute_sound (Mute sound) bool false
 
 [Client]
 
+#    Clickable weblinks (middle-click or ctrl-left-click) enabled in chat console output.
+clickable_chat_weblinks (Chat weblinks) bool false
+
+#    Optional override for chat weblink color.
+chat_weblink_color (Weblink color) string
+
 [*Network]
 
 #    Address to connect to.
index 718cb0c75d0210c6dc696a50781c976bdf51efae..b252f4f7043890857b7074ecddff75384d4497d2 100644 (file)
 # Client
 #
 
+#    If enabled, http links in chat can be middle-clicked or ctrl-left-clicked to open the link in the OS's default web browser.
+#    type: bool
+# clickable_chat_weblinks = false
+
+#    If clickable_chat_weblinks is enabled, specify the color (as 24-bit hexadecimal) of weblinks in chat.
+#    type: string
+# chat_weblink_color = #8888FF
+
 ## Network
 
 #    Address to connect to.
index 4ed1e2434275b7b487d5d27cf18392cc99df2e47..53b706f5f8f322336067b9d93c0b17c0cd8f4575 100644 (file)
@@ -6551,3 +6551,12 @@ msgid ""
 "be queued.\n"
 "This should be lower than curl_parallel_limit."
 msgstr ""
+
+#: src/gui/guiChatConsole.cpp
+msgid "Opening webpage"
+msgstr ""
+
+#: src/gui/guiChatConsole.cpp
+msgid "Failed to open webpage"
+msgstr ""
+
index c9317a079dadd73be72c4266e5d2e36a7fb8921f..e44d73ac078e6d63a1f2db44e6362dc94a6594fe 100644 (file)
@@ -35,6 +35,17 @@ ChatBuffer::ChatBuffer(u32 scrollback):
        if (m_scrollback == 0)
                m_scrollback = 1;
        m_empty_formatted_line.first = true;
+
+       m_cache_clickable_chat_weblinks = false;
+       // Curses mode cannot access g_settings here
+       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 std::wstring &name, const std::wstring &text)
@@ -263,78 +274,144 @@ u32 ChatBuffer::formatChatLine(const ChatLine& line, u32 cols,
        //EnrichedString line_text(line.text);
 
        next_line.first = true;
-       bool text_processing = false;
+       // Set/use forced newline after the last frag in each line
+       bool mark_newline = false;
 
        // Produce fragments and layout them into lines
-       while (!next_frags.empty() || in_pos < line.text.size())
-       {
+       while (!next_frags.empty() || in_pos < line.text.size()) {
+               mark_newline = false; // now using this to USE line-end frag
+
                // Layout fragments into lines
-               while (!next_frags.empty())
-               {
+               while (!next_frags.empty()) {
                        ChatFormattedFragment& frag = next_frags[0];
-                       if (frag.text.size() <= cols - out_column)
-                       {
+
+                       // Force newline after this frag, if marked
+                       if (frag.column == INT_MAX)
+                               mark_newline = true;
+
+                       if (frag.text.size() <= cols - out_column) {
                                // Fragment fits into current line
                                frag.column = out_column;
                                next_line.fragments.push_back(frag);
                                out_column += frag.text.size();
                                next_frags.erase(next_frags.begin());
-                       }
-                       else
-                       {
+                       } else {
                                // Fragment does not fit into current line
                                // So split it up
                                temp_frag.text = frag.text.substr(0, cols - out_column);
                                temp_frag.column = out_column;
-                               //temp_frag.bold = frag.bold;
+                               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 || text_processing)
-                       {
+
+                       if (out_column == cols || mark_newline) {
                                // End the current line
                                destination.push_back(next_line);
                                num_added++;
                                next_line.fragments.clear();
                                next_line.first = false;
 
-                               out_column = text_processing ? hanging_indentation : 0;
+                               out_column = hanging_indentation;
+                               mark_newline = false;
                        }
                }
 
-               // Produce fragment
-               if (in_pos < line.text.size())
-               {
-                       u32 remaining_in_input = line.text.size() - in_pos;
-                       u32 remaining_in_output = cols - out_column;
+               // Produce fragment(s) for next formatted line
+               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;  // now using this to SET line-end frag
+
+               // Construct all frags for next output line
+               while (!mark_newline) {
                        // Determine a fragment length <= the minimum of
                        // remaining_in_{in,out}put. Try to end the fragment
                        // on a word boundary.
-                       u32 frag_length = 1, space_pos = 0;
+                       u32 frag_length = 0, space_pos = 0;
+                       u32 remaining_in_input = line.text.size() - in_pos;
+
+                       if (m_cache_clickable_chat_weblinks) {
+                               // Note: unsigned(-1) on fail
+                               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(line.text.getString()[in_pos + frag_length]))
+                                       frag_length < remaining_in_output) {
+                               if (iswspace(linestring[in_pos + frag_length]))
                                        space_pos = frag_length;
                                ++frag_length;
                        }
+
+                       if (http_pos >= remaining_in_output) {
+                               // Http not in range, grab until space or EOL, halt as normal.
+                               // Note this works because (http_pos = npos) is unsigned(-1)
+
+                               mark_newline = true;
+                       } else if (http_pos == 0) {
+                               // At http, grab ALL until FIRST whitespace or end marker. loop.
+                               // If at end of string, next loop will be empty string to mark end of weblink.
+
+                               frag_length = 6;  // Frag is at least "http://"
+
+                               // Chars to mark end of weblink
+                               // TODO? replace this with a safer (slower) regex whitelist?
+                               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];
+                               }
+
+                               space_pos = frag_length - 1;
+                               // This frag may need to be force-split. That's ok, urls aren't "words"
+                               if (frag_length >= remaining_in_output) {
+                                       mark_newline = true;
+                               }
+                       } else {
+                               // Http in range, grab until http, loop
+
+                               space_pos = http_pos - 1;
+                               frag_length = http_pos;
+                       }
+
+                       // Include trailing space in current frag
                        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 = 0;
-                       //temp_frag.bold = 0;
+                       // A hack so this frag remembers mark_newline for the layout phase
+                       temp_frag.column = mark_newline ? INT_MAX : 0;
+
+                       if (http_pos == 0) {
+                               // Discard color stuff from the source frag
+                               temp_frag.text = EnrichedString(temp_frag.text.getString());
+                               temp_frag.text.setDefaultColor(m_cache_chat_weblink_color);
+                               // Set weblink in the frag meta
+                               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;
-                       text_processing = true;
+                       remaining_in_output -= std::min(frag_length, remaining_in_output);
                }
        }
 
        // End the last line
-       if (num_added == 0 || !next_line.fragments.empty())
-       {
+       if (num_added == 0 || !next_line.fragments.empty()) {
                destination.push_back(next_line);
                num_added++;
        }
index 0b98e4d3c0507118b6485a70754998ad36ba128b..aabb0821eccdeb133900dd381d856aacbe09c11f 100644 (file)
@@ -57,6 +57,8 @@ struct ChatFormattedFragment
        EnrichedString text;
        // starting column
        u32 column;
+       // web link is empty for most frags
+       std::string weblink;
        // formatting
        //u8 bold:1;
 };
@@ -118,6 +120,7 @@ class ChatBuffer
                        std::vector<ChatFormattedLine>& destination) const;
 
        void resize(u32 scrollback);
+
 protected:
        s32 getTopScrollPos() const;
        s32 getBottomScrollPos() const;
@@ -138,6 +141,11 @@ class ChatBuffer
        std::vector<ChatFormattedLine> m_formatted;
        // Empty formatted line, for error returns
        ChatFormattedLine m_empty_formatted_line;
+
+       // Enable clickable chat weblinks
+       bool m_cache_clickable_chat_weblinks;
+       // Color of clickable chat weblinks
+       irr::video::SColor m_cache_chat_weblink_color;
 };
 
 class ChatPrompt
index 0895bf8986cf7544487a002cd87cd25bfc079bbb..6791fccf567e3a228684f761009e7fc95934a2b6 100644 (file)
@@ -65,6 +65,8 @@ void set_default_settings()
        settings->setDefault("max_out_chat_queue_size", "20");
        settings->setDefault("pause_on_lost_focus", "false");
        settings->setDefault("enable_register_confirmation", "true");
+       settings->setDefault("clickable_chat_weblinks", "false");
+       settings->setDefault("chat_weblink_color", "#8888FF");
 
        // Keymap
        settings->setDefault("remote_port", "30000");
index baaaea5e8811875300cd56b0a1e01587326c3701..85617d8623d8038b513f3fe038fd1a42a8ee3bc5 100644 (file)
@@ -41,6 +41,10 @@ inline u32 clamp_u8(s32 value)
        return (u32) MYMIN(MYMAX(value, 0), 255);
 }
 
+inline bool isInCtrlKeys(const irr::EKEY_CODE& kc)
+{
+       return kc == KEY_LCONTROL || kc == KEY_RCONTROL || kc == KEY_CONTROL;
+}
 
 GUIChatConsole::GUIChatConsole(
                gui::IGUIEnvironment* env,
@@ -91,6 +95,10 @@ GUIChatConsole::GUIChatConsole(
 
        // set default cursor options
        setCursor(true, true, 2.0, 0.1);
+
+       // track ctrl keys for mouse event
+       m_is_ctrl_down = false;
+       m_cache_clickable_chat_weblinks = g_settings->getBool("clickable_chat_weblinks");
 }
 
 GUIChatConsole::~GUIChatConsole()
@@ -405,8 +413,21 @@ bool GUIChatConsole::OnEvent(const SEvent& event)
 
        ChatPrompt &prompt = m_chat_backend->getPrompt();
 
-       if(event.EventType == EET_KEY_INPUT_EVENT && event.KeyInput.PressedDown)
+       if (event.EventType == EET_KEY_INPUT_EVENT && !event.KeyInput.PressedDown)
+       {
+               // CTRL up
+               if (isInCtrlKeys(event.KeyInput.Key))
+               {
+                       m_is_ctrl_down = false;
+               }
+       }
+       else if(event.EventType == EET_KEY_INPUT_EVENT && event.KeyInput.PressedDown)
        {
+               // CTRL down
+               if (isInCtrlKeys(event.KeyInput.Key)) {
+                       m_is_ctrl_down = true;
+               }
+
                // Key input
                if (KeyPress(event.KeyInput) == getKeySetting("keymap_console")) {
                        closeConsole();
@@ -613,11 +634,24 @@ bool GUIChatConsole::OnEvent(const SEvent& event)
        }
        else if(event.EventType == EET_MOUSE_INPUT_EVENT)
        {
-               if(event.MouseInput.Event == EMIE_MOUSE_WHEEL)
+               if (event.MouseInput.Event == EMIE_MOUSE_WHEEL)
                {
                        s32 rows = myround(-3.0 * event.MouseInput.Wheel);
                        m_chat_backend->scroll(rows);
                }
+               // Middle click or ctrl-click opens weblink, if enabled in config
+               else if(m_cache_clickable_chat_weblinks && (
+                               event.MouseInput.Event == EMIE_MMOUSE_PRESSED_DOWN ||
+                               (event.MouseInput.Event == EMIE_LMOUSE_PRESSED_DOWN && m_is_ctrl_down)
+                               ))
+               {
+                       // If clicked within console output region
+                       if (event.MouseInput.Y / m_fontsize.Y < (m_height / m_fontsize.Y) - 1 )
+                       {
+                               // Translate pixel position to font position
+                               middleClick(event.MouseInput.X / m_fontsize.X, event.MouseInput.Y / m_fontsize.Y);
+                       }
+               }
        }
 #if (IRRLICHT_VERSION_MT_REVISION >= 2)
        else if(event.EventType == EET_STRING_INPUT_EVENT)
@@ -640,3 +674,63 @@ void GUIChatConsole::setVisible(bool visible)
        }
 }
 
+void GUIChatConsole::middleClick(s32 col, s32 row)
+{
+       // Prevent accidental rapid clicking
+       static u64 s_oldtime = 0;
+       u64 newtime = porting::getTimeMs();
+
+       // 0.6 seconds should suffice
+       if (newtime - s_oldtime < 600)
+               return;
+       s_oldtime = newtime;
+
+       const std::vector<ChatFormattedFragment> &
+                       frags = m_chat_backend->getConsoleBuffer().getFormattedLine(row).fragments;
+       std::string weblink = ""; // from frag meta
+
+       // Identify targetted fragment, if exists
+       int indx = frags.size() - 1;
+       if (indx < 0) {
+               // Invalid row, frags is empty
+               return;
+       }
+       // Scan from right to left, offset by 1 font space because left margin
+       while (indx > -1 && (u32)col < frags[indx].column + 1) {
+               --indx;
+       }
+       if (indx > -1) {
+               weblink = frags[indx].weblink;
+               // Note if(indx < 0) then a frag somehow had a corrupt column field
+       }
+
+       /*
+       // Debug help. Please keep this in case adjustments are made later.
+       std::string ws;
+       ws = "Middleclick: (" + std::to_string(col) + ',' + std::to_string(row) + ')' + " frags:";
+       // show all frags <position>(<length>) for the clicked row
+       for (u32 i=0;i<frags.size();++i) {
+               if (indx == int(i))
+                       // tag the actual clicked frag
+                       ws += '*';
+               ws += std::to_string(frags.at(i).column) + '('
+                       + std::to_string(frags.at(i).text.size()) + "),";
+       }
+       actionstream << ws << std::endl;
+       */
+
+       // User notification
+       if (weblink.size() != 0) {
+               std::ostringstream msg;
+               msg << " * ";
+               if (porting::open_url(weblink)) {
+                       msg << gettext("Opening webpage");
+               }
+               else {
+                       msg << gettext("Failed to open webpage");
+               }
+               msg << " '" << weblink << "'";
+               msg.flush();
+               m_chat_backend->addUnparsedMessage(utf8_to_wide(msg.str()));
+       }
+}
index 1152f2b2d515b0f7464056073a473f08e8faab81..32628f0d83bafa38e55c4ebbe99afc3c877f4275 100644 (file)
@@ -84,6 +84,9 @@ class GUIChatConsole : public gui::IGUIElement
        void drawText();
        void drawPrompt();
 
+       // If clicked fragment has a web url, send it to the system default web browser
+       void middleClick(s32 col, s32 row);
+
 private:
        ChatBackend* m_chat_backend;
        Client* m_client;
@@ -126,4 +129,9 @@ class GUIChatConsole : public gui::IGUIElement
        // font
        gui::IGUIFont *m_font = nullptr;
        v2u32 m_fontsize;
+
+       // Enable clickable chat weblinks
+       bool m_cache_clickable_chat_weblinks;
+       // Track if a ctrl key is currently held down
+       bool m_is_ctrl_down;
 };