From c0314212a0d951494fe6397fa53a9c5689a3ff87 Mon Sep 17 00:00:00 2001
From: fiaxh <git@mx.ax.lt>
Date: Fri, 24 Mar 2017 00:15:00 +0100
Subject: Tab completion for MUC occupants

---
 main/CMakeLists.txt                                |   4 +-
 main/data/chat_input.ui                            |   2 +-
 main/data/conversation_summary/view.ui             |   1 +
 main/src/ui/chat_input.vala                        | 127 ---------------------
 .../src/ui/chat_input/occupants_tab_completer.vala | 116 +++++++++++++++++++
 main/src/ui/chat_input/smiley_converter.vala       |  58 ++++++++++
 main/src/ui/chat_input/view.vala                   | 102 +++++++++++++++++
 main/src/ui/unified_window.vala                    |   4 +-
 8 files changed, 283 insertions(+), 131 deletions(-)
 delete mode 100644 main/src/ui/chat_input.vala
 create mode 100644 main/src/ui/chat_input/occupants_tab_completer.vala
 create mode 100644 main/src/ui/chat_input/smiley_converter.vala
 create mode 100644 main/src/ui/chat_input/view.vala

(limited to 'main')

diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt
index 60d91c7b..ace08960 100644
--- a/main/CMakeLists.txt
+++ b/main/CMakeLists.txt
@@ -68,7 +68,9 @@ SOURCES
     src/ui/add_conversation/list_row.vala
     src/ui/add_conversation/select_jid_fragment.vala
     src/ui/avatar_generator.vala
-    src/ui/chat_input.vala
+    src/ui/chat_input/occupants_tab_completer.vala
+    src/ui/chat_input/smiley_converter.vala
+    src/ui/chat_input/view.vala
     src/ui/conversation_list_titlebar.vala
     src/ui/conversation_selector/chat_row.vala
     src/ui/conversation_selector/conversation_row.vala
diff --git a/main/data/chat_input.ui b/main/data/chat_input.ui
index f8a1c2f7..2436ff82 100644
--- a/main/data/chat_input.ui
+++ b/main/data/chat_input.ui
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <interface>
     <requires lib="gtk+" version="3.22"/>
-    <template class="DinoUiChatInput">
+    <template class="DinoUiChatInputView">
         <property name="hexpand">True</property>
         <property name="orientation">horizontal</property>
         <property name="margin">5</property>
diff --git a/main/data/conversation_summary/view.ui b/main/data/conversation_summary/view.ui
index d00314fc..07fb7b71 100644
--- a/main/data/conversation_summary/view.ui
+++ b/main/data/conversation_summary/view.ui
@@ -11,6 +11,7 @@
                 <property name="visible">True</property>
                 <child>
                     <object class="GtkScrolledWindow" id="scrolled">
+                        <property name="hscrollbar_policy">never</property>
                         <property name="visible">True</property>
                         <child>
                             <object class="GtkBox">
diff --git a/main/src/ui/chat_input.vala b/main/src/ui/chat_input.vala
deleted file mode 100644
index 1ca38786..00000000
--- a/main/src/ui/chat_input.vala
+++ /dev/null
@@ -1,127 +0,0 @@
-using Gdk;
-using Gee;
-using Gtk;
-
-using Dino.Entities;
-using Xmpp;
-
-namespace Dino.Ui {
-
-[GtkTemplate (ui = "/org/dino-im/chat_input.ui")]
-public class ChatInput : Box {
-
-    [GtkChild] private ScrolledWindow scrolled;
-    [GtkChild] private TextView text_input;
-
-    private Conversation? conversation;
-    private StreamInteractor stream_interactor;
-    private HashMap<Conversation, string> entry_cache = new HashMap<Conversation, string>(Conversation.hash_func, Conversation.equals_func);
-    private static HashMap<string, string> smiley_translations = new HashMap<string, string>();
-    private int vscrollbar_min_height;
-
-    static construct {
-        smiley_translations[":)"] = "🙂";
-        smiley_translations[":D"] = "😀";
-        smiley_translations[";)"] = "😉";
-        smiley_translations["O:)"] = "😇";
-        smiley_translations["]:>"] = "😈";
-        smiley_translations[":o"] = "😮";
-        smiley_translations[":P"] = "😛";
-        smiley_translations[";P"] = "😜";
-        smiley_translations[":("] = "🙁";
-        smiley_translations[":'("] = "😢";
-        smiley_translations[":/"] = "😕";
-        smiley_translations["-.-"] = "😑";
-    }
-
-    public ChatInput(StreamInteractor stream_interactor) {
-        this.stream_interactor = stream_interactor;
-        scrolled.get_vscrollbar().get_preferred_height(out vscrollbar_min_height, null);
-        scrolled.vadjustment.notify["upper"].connect_after(on_upper_notify);
-        text_input.key_press_event.connect(on_text_input_key_press);
-        text_input.buffer.changed.connect(on_text_input_changed);
-    }
-
-    public void initialize_for_conversation(Conversation conversation) {
-        if (this.conversation != null) entry_cache[this.conversation] = text_input.buffer.text;
-        this.conversation = conversation;
-
-        text_input.buffer.changed.disconnect(on_text_input_changed);
-        text_input.buffer.text = "";
-        if (entry_cache.has_key(conversation)) {
-            text_input.buffer.text = entry_cache[conversation];
-        }
-        text_input.buffer.changed.connect(on_text_input_changed);
-
-        text_input.grab_focus();
-    }
-
-    private void send_text() {
-        string text = text_input.buffer.text;
-        if (text.has_prefix("/")) {
-            string[] token = text.split(" ", 2);
-            switch(token[0]) {
-                case "/kick":
-                    stream_interactor.get_module(MucManager.IDENTITY).kick(conversation.account, conversation.counterpart, token[1]);
-                    break;
-                case "/me":
-                    stream_interactor.get_module(MessageManager.IDENTITY).send_message(text, conversation);
-                    break;
-                case "/nick":
-                    stream_interactor.get_module(MucManager.IDENTITY).change_nick(conversation.account, conversation.counterpart, token[1]);
-                    break;
-                case "/topic":
-                    stream_interactor.get_module(MucManager.IDENTITY).change_subject(conversation.account, conversation.counterpart, token[1]);
-                    break;
-            }
-        } else {
-            stream_interactor.get_module(MessageManager.IDENTITY).send_message(text, conversation);
-        }
-        text_input.buffer.text = "";
-    }
-
-    private bool on_text_input_key_press(EventKey event) {
-        if (event.keyval == Key.space || event.keyval == Key.Return) {
-            check_convert_smiley();
-        }
-        if (event.keyval == Key.Return) {
-            if ((event.state & ModifierType.SHIFT_MASK) > 0) {
-                text_input.buffer.insert_at_cursor("\n", 1);
-            } else if (text_input.buffer.text != ""){
-                send_text();
-            }
-            return true;
-        }
-        return false;
-    }
-
-    private void on_upper_notify() {
-        scrolled.vadjustment.value = scrolled.vadjustment.upper - scrolled.vadjustment.page_size;
-
-        // hack for vscrollbar not requiring space and making textview higher //TODO doesn't resize immediately
-        scrolled.get_vscrollbar().visible = (scrolled.vadjustment.upper > scrolled.max_content_height - 2 * vscrollbar_min_height);
-    }
-
-    private void check_convert_smiley() {
-        if (Dino.Settings.instance().convert_utf8_smileys) {
-            foreach (string smiley in smiley_translations.keys) {
-                if (text_input.buffer.text.has_suffix(smiley)) {
-                    if (text_input.buffer.text.length == smiley.length ||
-                            text_input.buffer.text[text_input.buffer.text.length - smiley.length - 1] == ' ') {
-                        text_input.buffer.text = text_input.buffer.text.substring(0, text_input.buffer.text.length - smiley.length) + smiley_translations[smiley];
-                    }
-                }
-            }
-        }
-    }
-
-    private void on_text_input_changed() {
-        if (text_input.buffer.text != "") {
-            stream_interactor.get_module(ChatInteraction.IDENTITY).on_message_entered(conversation);
-        } else {
-            stream_interactor.get_module(ChatInteraction.IDENTITY).on_message_cleared(conversation);
-        }
-    }
-}
-
-}
\ No newline at end of file
diff --git a/main/src/ui/chat_input/occupants_tab_completer.vala b/main/src/ui/chat_input/occupants_tab_completer.vala
new file mode 100644
index 00000000..93b0e7ff
--- /dev/null
+++ b/main/src/ui/chat_input/occupants_tab_completer.vala
@@ -0,0 +1,116 @@
+using Gdk;
+using Gee;
+using Gtk;
+
+using Dino.Entities;
+
+namespace Dino.Ui.ChatInput {
+
+/**
+ * - With given prefix: Complete from occupant list (sorted lexicographically)
+ * - W/o prefix: Complete from received messages (most recent first)
+ * - At the start (with ",") and in the middle of a text
+ * - Backwards tabbing
+ */
+class OccupantsTabCompletor {
+
+    private StreamInteractor stream_interactor;
+    private Conversation? conversation;
+    private TextView text_input;
+
+    private Gee.List<string> completions = new ArrayList<string>();
+    private bool active = false;
+    private int index = -1;
+
+    public OccupantsTabCompletor(StreamInteractor stream_interactor, TextView text_input) {
+        this.stream_interactor = stream_interactor;
+        this.text_input = text_input;
+
+        text_input.key_press_event.connect(on_text_input_key_press);
+    }
+
+    public void initialize_for_conversation(Conversation conversation) {
+        this.conversation = conversation;
+    }
+
+    public bool on_text_input_key_press(EventKey event) {
+        if (conversation.type_ == Conversation.Type.GROUPCHAT) {
+            if (event.keyval == Key.Tab || event.keyval == Key.ISO_Left_Tab) {
+                string text = text_input.buffer.text;
+                int start_index = int.max(text.last_index_of(" "), text.last_index_of("\n")) + 1;
+                string word = text.substring(start_index);
+                if (!active) {
+                    if (word == "") {
+                        completions = generate_completions_from_messages();
+                    } else {
+                        completions = generate_completions_from_occupants(word);
+                    }
+                    if (completions.size > 0) {
+                        active = true;
+                        index = -1;
+                    }
+                }
+                if (event.keyval != Key.ISO_Group_Shift && active) {
+                    text_input.buffer.text = next_completion(event.keyval == Key.ISO_Left_Tab);
+                    return true;
+                }
+            } else if (event.keyval != Key.Shift_L && active) {
+                active = false;
+            }
+        }
+        return false;
+    }
+
+    private string next_completion(bool backwards) {
+        string text = text_input.buffer.text;
+        int start_index = int.max(text.last_index_of(" "), text.last_index_of("\n")) + 1;
+        string prev_completion = text.substring(start_index);
+        if (index > -1) {
+            start_index = int.max(
+                text.substring(0, text.length - 1).last_index_of(" "),
+                text.substring(0, text.length - 1).last_index_of("\n")
+            ) + 1;
+            prev_completion = text.substring(start_index);
+        }
+        if (backwards) {
+            index = int.max(index, 0) - 1;
+            if (index < 0) index = completions.size - 1;
+        } else {
+            index = (index + 1) % (completions.size);
+        }
+        if (start_index == 0) {
+            return completions[index] + ", ";
+        } else {
+            return text.substring(0, text.length - prev_completion.length) + completions[index] + " ";
+        }
+    }
+
+    private Gee.List<string> generate_completions_from_messages() {
+        Gee.List<string> ret = new ArrayList<string>();
+        Gee.List<Message>? messages = stream_interactor.get_module(MessageManager.IDENTITY).get_messages(conversation, 10);
+        if (messages != null) {
+            for (int i = messages.size - 1; i > 0; i--) {
+                string resourcepart = messages[i].from.resourcepart;
+                string own_nick = stream_interactor.get_module(MucManager.IDENTITY).get_nick(conversation.counterpart, conversation.account);
+                if (resourcepart != null && resourcepart != "" && resourcepart != own_nick && !ret.contains(resourcepart)) {
+                    ret.add(resourcepart);
+                }
+            }
+        }
+        return ret;
+    }
+
+    private Gee.List<string> generate_completions_from_occupants(string prefix) {
+        Gee.List<string> ret = new ArrayList<string>();
+        Gee.List<Jid>? occupants = stream_interactor.get_module(MucManager.IDENTITY).get_other_occupants(conversation.counterpart, conversation.account);
+        if (occupants != null) {
+            foreach (Jid jid in occupants) {
+                if (jid.resourcepart.to_string().has_prefix(prefix)) ret.add(jid.resourcepart.to_string());
+            }
+        }
+        ret.sort();
+        return ret;
+    }
+}
+
+}
\ No newline at end of file
diff --git a/main/src/ui/chat_input/smiley_converter.vala b/main/src/ui/chat_input/smiley_converter.vala
new file mode 100644
index 00000000..73413972
--- /dev/null
+++ b/main/src/ui/chat_input/smiley_converter.vala
@@ -0,0 +1,58 @@
+using Gdk;
+using Gee;
+using Gtk;
+
+using Dino.Entities;
+
+namespace Dino.Ui.ChatInput {
+
+class SmileyConverter {
+
+    private StreamInteractor stream_interactor;
+    private TextView text_input;
+    private static HashMap<string, string> smiley_translations = new HashMap<string, string>();
+
+    static construct {
+        smiley_translations[":)"] = "🙂";
+        smiley_translations[":D"] = "😀";
+        smiley_translations[";)"] = "😉";
+        smiley_translations["O:)"] = "😇";
+        smiley_translations["O:-)"] = "😇";
+        smiley_translations["]:>"] = "😈";
+        smiley_translations[":o"] = "😮";
+        smiley_translations[":P"] = "😛";
+        smiley_translations[";P"] = "😜";
+        smiley_translations[":("] = "🙁";
+        smiley_translations[":'("] = "😢";
+        smiley_translations[":/"] = "😕";
+    }
+
+    public SmileyConverter(StreamInteractor stream_interactor, TextView text_input) {
+        this.stream_interactor = stream_interactor;
+        this.text_input = text_input;
+
+        text_input.key_press_event.connect(on_text_input_key_press);
+    }
+
+    public bool on_text_input_key_press(EventKey event) {
+        if (event.keyval == Key.space || event.keyval == Key.Return) {
+            check_convert();
+        }
+        return false;
+    }
+
+    private void check_convert() {
+        if (Dino.Settings.instance().convert_utf8_smileys) {
+            foreach (string smiley in smiley_translations.keys) {
+                if (text_input.buffer.text.has_suffix(smiley)) {
+                    if (text_input.buffer.text.length == smiley.length ||
+                            text_input.buffer.text[text_input.buffer.text.length - smiley.length - 1] == ' ') {
+                        text_input.buffer.text = text_input.buffer.text.substring(0, text_input.buffer.text.length - smiley.length) + smiley_translations[smiley];
+                    }
+                }
+            }
+        }
+    }
+}
+
+}
\ No newline at end of file
diff --git a/main/src/ui/chat_input/view.vala b/main/src/ui/chat_input/view.vala
new file mode 100644
index 00000000..310c0f35
--- /dev/null
+++ b/main/src/ui/chat_input/view.vala
@@ -0,0 +1,102 @@
+using Gdk;
+using Gee;
+using Gtk;
+
+using Dino.Entities;
+using Xmpp;
+
+namespace Dino.Ui.ChatInput {
+
+[GtkTemplate (ui = "/org/dino-im/chat_input.ui")]
+public class View : Box {
+
+    [GtkChild] private ScrolledWindow scrolled;
+    [GtkChild] private TextView text_input;
+
+    private StreamInteractor stream_interactor;
+    private Conversation? conversation;
+    private HashMap<Conversation, string> entry_cache = new HashMap<Conversation, string>(Conversation.hash_func, Conversation.equals_func);
+    private int vscrollbar_min_height;
+    private OccupantsTabCompletor occupants_tab_completor;
+    private SmileyConverter smiley_converter;
+
+    public View(StreamInteractor stream_interactor) {
+        this.stream_interactor = stream_interactor;
+        occupants_tab_completor = new OccupantsTabCompletor(stream_interactor, text_input);
+        smiley_converter = new SmileyConverter(stream_interactor, text_input);
+
+        scrolled.get_vscrollbar().get_preferred_height(out vscrollbar_min_height, null);
+        scrolled.vadjustment.notify["upper"].connect_after(on_upper_notify);
+        text_input.key_press_event.connect(on_text_input_key_press);
+        text_input.buffer.changed.connect(on_text_input_changed);
+    }
+
+    public void initialize_for_conversation(Conversation conversation) {
+        occupants_tab_completor.initialize_for_conversation(conversation);
+
+        if (this.conversation != null) entry_cache[this.conversation] = text_input.buffer.text;
+        this.conversation = conversation;
+
+        text_input.buffer.changed.disconnect(on_text_input_changed);
+        text_input.buffer.text = "";
+        if (entry_cache.has_key(conversation)) {
+            text_input.buffer.text = entry_cache[conversation];
+        }
+        text_input.buffer.changed.connect(on_text_input_changed);
+
+        text_input.grab_focus();
+    }
+
+    private void send_text() {
+        string text = text_input.buffer.text;
+        if (text.has_prefix("/")) {
+            string[] token = text.split(" ", 2);
+            switch(token[0]) {
+                case "/kick":
+                    stream_interactor.get_module(MucManager.IDENTITY).kick(conversation.account, conversation.counterpart, token[1]);
+                    break;
+                case "/me":
+                    stream_interactor.get_module(MessageManager.IDENTITY).send_message(text, conversation);
+                    break;
+                case "/nick":
+                    stream_interactor.get_module(MucManager.IDENTITY).change_nick(conversation.account, conversation.counterpart, token[1]);
+                    break;
+                case "/topic":
+                    stream_interactor.get_module(MucManager.IDENTITY).change_subject(conversation.account, conversation.counterpart, token[1]);
+                    break;
+            }
+        } else {
+            stream_interactor.get_module(MessageManager.IDENTITY).send_message(text, conversation);
+        }
+        text_input.buffer.text = "";
+    }
+
+    private bool on_text_input_key_press(EventKey event) {
+        if (event.keyval == Key.Return) {
+            if ((event.state & ModifierType.SHIFT_MASK) > 0) {
+                text_input.buffer.insert_at_cursor("\n", 1);
+            } else if (text_input.buffer.text != ""){
+                send_text();
+            }
+            return true;
+        }
+        return false;
+    }
+
+    private void on_upper_notify() {
+        scrolled.vadjustment.value = scrolled.vadjustment.upper - scrolled.vadjustment.page_size;
+
+        // hack for vscrollbar not requiring space and making textview higher //TODO doesn't resize immediately
+        scrolled.get_vscrollbar().visible = (scrolled.vadjustment.upper > scrolled.max_content_height - 2 * vscrollbar_min_height);
+    }
+
+    private void on_text_input_changed() {
+        if (text_input.buffer.text != "") {
+            stream_interactor.get_module(ChatInteraction.IDENTITY).on_message_entered(conversation);
+        } else {
+            stream_interactor.get_module(ChatInteraction.IDENTITY).on_message_cleared(conversation);
+        }
+    }
+}
+
+}
\ No newline at end of file
diff --git a/main/src/ui/unified_window.vala b/main/src/ui/unified_window.vala
index 4a409128..66a4a087 100644
--- a/main/src/ui/unified_window.vala
+++ b/main/src/ui/unified_window.vala
@@ -9,7 +9,7 @@ public class UnifiedWindow : Window {
 
     private NoAccountsPlaceholder accounts_placeholder = new NoAccountsPlaceholder() { visible=true };
     private NoConversationsPlaceholder conversations_placeholder = new NoConversationsPlaceholder() { visible=true };
-    private ChatInput chat_input;
+    private ChatInput.View chat_input;
     private ConversationListTitlebar conversation_list_titlebar;
     private ConversationSelector.View filterable_conversation_list;
     private ConversationSummary.View conversation_frame;
@@ -62,7 +62,7 @@ public class UnifiedWindow : Window {
     }
 
     private void setup_unified() {
-        chat_input = new ChatInput(stream_interactor) { visible=true };
+        chat_input = new ChatInput.View(stream_interactor) { visible=true };
         conversation_frame = new ConversationSummary.View(stream_interactor) { visible=true };
         filterable_conversation_list = new ConversationSelector.View(stream_interactor) { visible=true };
 
-- 
cgit v1.2.3-70-g09d2