$ git clone http://tcclient.ion.nu/tc_client.git
commit 972a0a57410f02abae88d6ead4459dd9261ec9fd
Author: Alicia <...>
Date:   Fri Jun 12 00:15:22 2015 +0200

    tc_client-gtk: added tabs for PMs.

diff --git a/ChangeLog b/ChangeLog
index 5bfe5f1..0277357 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -7,6 +7,7 @@ tc_client-gtk: redesigned the startup window to better support frequenting multi
 tc_client-gtk and camviewer: added compatibility code to build on windows (for now the tc_client core still needs to be built with cygwin for windows)
 tc_client-gtk: made the options for notifications and youtube videos work for windows.
 tc_client-gtk: if there are no saved channels, show a placeholder text instructing how to add a channel.
+tc_client-gtk: added tabs for PMs.
 0.32:
 Added an 'install' make target, adjusted utilities to run tc_client from PATH unless they were run from the build directory (i.e. './<executable>')
 Provide feedback on the /ban command.
diff --git a/gtkgui.glade b/gtkgui.glade
index e817f1b..b0f0785 100644
--- a/gtkgui.glade
+++ b/gtkgui.glade
@@ -413,17 +413,35 @@
                     <property name="visible">True</property>
                     <property name="can_focus">False</property>
                     <child>
-                      <object class="GtkScrolledWindow" id="chatscroll">
+                      <object class="GtkNotebook" id="tabs">
                         <property name="visible">True</property>
                         <property name="can_focus">True</property>
+                        <property name="scrollable">True</property>
+                        <property name="enable_popup">True</property>
                         <child>
-                          <object class="GtkTextView" id="chatview">
+                          <object class="GtkScrolledWindow" id="chatscroll">
                             <property name="visible">True</property>
                             <property name="can_focus">True</property>
-                            <property name="editable">False</property>
-                            <property name="wrap_mode">char</property>
-                            <property name="cursor_visible">False</property>
+                            <child>
+                              <object class="GtkTextView" id="chatview">
+                                <property name="visible">True</property>
+                                <property name="can_focus">True</property>
+                                <property name="editable">False</property>
+                                <property name="wrap_mode">char</property>
+                                <property name="cursor_visible">False</property>
+                              </object>
+                            </child>
+                          </object>
+                        </child>
+                        <child type="tab">
+                          <object class="GtkLabel" id="label17">
+                            <property name="visible">True</property>
+                            <property name="can_focus">False</property>
+                            <property name="label" translatable="yes">Main</property>
                           </object>
+                          <packing>
+                            <property name="tab_fill">False</property>
+                          </packing>
                         </child>
                       </object>
                       <packing>
diff --git a/utilities/gtk/camviewer.c b/utilities/gtk/camviewer.c
index aaa616d..c95ba35 100644
--- a/utilities/gtk/camviewer.c
+++ b/utilities/gtk/camviewer.c
@@ -73,7 +73,7 @@ struct viddata
   int audiopipe;
   SwrContext* swrctx;
 #endif
-  GtkTextBuffer* buffer; // TODO: struct buffer array, for PMs
+  GtkTextBuffer* buffer;
   GtkAdjustment* scroll;
 };
 struct viddata* data;
@@ -125,34 +125,58 @@ void updatescaling(struct viddata* data, unsigned int width, unsigned int height
   }
 }
 
-void printchat(struct viddata* data, const char* text)
+void printchat(const char* text, const char* pm)
 {
-  char bottom=autoscroll_before(data->scroll);
+  GtkAdjustment* scroll;
+  GtkTextBuffer* buffer;
+  struct user* user;
+  if(pm && (user=finduser(pm)))
+  {
+    pm_open(pm, 0);
+    scroll=user->pm_scroll;
+    buffer=user->pm_buffer;
+  }else{
+    scroll=data->scroll;
+    buffer=data->buffer;
+  }
+  char bottom=autoscroll_before(scroll);
   // Insert new content
   GtkTextIter end;
-  gtk_text_buffer_get_end_iter(data->buffer, &end);
-  gtk_text_buffer_insert(data->buffer, &end, "\n", -1);
-  gtk_text_buffer_insert(data->buffer, &end, text, -1);
-  if(bottom){autoscroll_after(data->scroll);}
+  gtk_text_buffer_get_end_iter(buffer, &end);
+  gtk_text_buffer_insert(buffer, &end, "\n", -1);
+  gtk_text_buffer_insert(buffer, &end, text, -1);
+  if(bottom){autoscroll_after(scroll);}
 }
 
-void printchat_color(struct viddata* data, const char* text, const char* color, unsigned int offset)
+void printchat_color(const char* text, const char* color, unsigned int offset, const char* pm)
 {
-  char bottom=autoscroll_before(data->scroll);
+  GtkAdjustment* scroll;
+  GtkTextBuffer* buffer;
+  struct user* user;
+  if(pm && (user=finduser(pm)))
+  {
+    pm_open(pm, 0);
+    scroll=user->pm_scroll;
+    buffer=user->pm_buffer;
+  }else{
+    scroll=data->scroll;
+    buffer=data->buffer;
+  }
+  char bottom=autoscroll_before(scroll);
   // Insert new content
   GtkTextIter end;
-  gtk_text_buffer_get_end_iter(data->buffer, &end);
-  gtk_text_buffer_insert(data->buffer, &end, "\n", -1);
+  gtk_text_buffer_get_end_iter(buffer, &end);
+  gtk_text_buffer_insert(buffer, &end, "\n", -1);
   int startnum=gtk_text_iter_get_offset(&end);
-  gtk_text_buffer_insert(data->buffer, &end, text, -1);
+  gtk_text_buffer_insert(buffer, &end, text, -1);
   // Set color if there was one
   if(color)
   {
     GtkTextIter start;
-    gtk_text_buffer_get_iter_at_offset(data->buffer, &start, startnum+offset);
-    gtk_text_buffer_apply_tag_by_name(data->buffer, color, &start, &end);
+    gtk_text_buffer_get_iter_at_offset(buffer, &start, startnum+offset);
+    gtk_text_buffer_apply_tag_by_name(buffer, color, &start, &end);
   }
-  if(bottom){autoscroll_after(data->scroll);}
+  if(bottom){autoscroll_after(scroll);}
 }
 
 char buf[1024];
@@ -169,7 +193,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   buf[i]=0;
   if(!strncmp(buf, "Currently online: ", 18))
   {
-    printchat(data, buf);
+    printchat(buf, 0);
     char* next=&buf[16];
     while(next)
     {
@@ -190,7 +214,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   // Start streams once we're properly connected
   if(!strncmp(buf, "Currently on cam: ", 18))
   {
-    printchat(data, buf);
+    printchat(buf, 0);
     char* next=&buf[16];
     while(next)
     {
@@ -210,7 +234,7 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   }
   if(buf[0]=='/') // For the /help text
   {
-    printchat(data, buf);
+    printchat(buf, 0);
     return 1;
   }
   // Remove escape codes and pick up the text color while we're at it
@@ -227,12 +251,12 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   // Timestamped events
   if(buf[0]=='['&&isdigit(buf[1])&&isdigit(buf[2])&&buf[3]==':'&&isdigit(buf[4])&&isdigit(buf[5])&&buf[6]==']'&&buf[7]==' ')
   {
+    char* pm=0;
     char* nick=&buf[8];
     space=strchr(nick, ' ');
     if(!space){return 1;}
     if(space[-1]==':')
     {
-// TODO: handle /msg (PMs)
       if(config_get_bool("soundradio_cmd"))
       {
 #ifdef _WIN32
@@ -285,11 +309,23 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
 #endif
         }
       }
+      // Handle incoming PMs
+      else if(!strncmp(space, " /msg ", 6))
+      {
+        char* msg=strchr(&space[6], ' ');
+        if(msg)
+        {
+          memmove(space, msg, strlen(msg)+1);
+          char* end=strchr(nick, ':');
+          pm=strndup(nick, end-nick);
+        }
+      }
     }
-// TODO: handle logging PMs
-    if(config_get_bool("enable_logging")){logger_write(buf, channel, 0);}
+    if(config_get_bool("enable_logging")){logger_write(buf, channel, pm);}
     // Insert new content
-    printchat_color(data, buf, color, 8);
+    printchat_color(buf, color, 8, pm);
+    pm_highlight(pm);
+    free(pm);
     if(space[-1]!=':') // Not a message
     {
       if(!strcmp(space, " entered the channel"))
@@ -327,14 +363,14 @@ gboolean handledata(GIOChannel* iochannel, GIOCondition condition, gpointer data
   }
   if(!strcmp(buf, "Changed color") || !strncmp(buf, "Current color: ", 15))
   {
-    printchat_color(data, buf, color, 0);
+    printchat_color(buf, color, 0, 0);
     free((void*)mycolor);
     mycolor=color;
     return 1;
   }
   if(!strncmp(buf, "Color ", 6))
   {
-    printchat_color(data, buf, color, 0);
+    printchat_color(buf, color, 0, 0);
   }
   free(color);
   if(space && !strcmp(space, " is a moderator."))
@@ -709,6 +745,30 @@ gboolean inputkeys(GtkWidget* widget, GdkEventKey* event, void* data)
 void sendmessage(GtkEntry* entry, struct viddata* data)
 {
   const char* msg=gtk_entry_get_text(entry);
+  char* pm=0;
+  if(!strncmp(msg, "/pm ", 4))
+  {
+    pm_open(&msg[4], 1);
+    gtk_entry_set_text(entry, "");
+    return;
+  }
+  else if(msg[0]!='/') // If we're in a PM tab, send messages as PMs
+  {
+    GtkNotebook* tabs=GTK_NOTEBOOK(gtk_builder_get_object(gui, "tabs"));
+    int num=gtk_notebook_get_current_page(tabs);
+    if(num>0)
+    {
+      GtkWidget* page=gtk_notebook_get_nth_page(tabs, num);
+      struct user* user=user_find_by_tab(page);
+      if(!user) // Person we were PMing with left
+      {
+        gtk_entry_set_text(entry, "");
+        return;
+      }
+      pm=strdup(user->nick);
+      dprintf(tc_client_in[1], "/msg %s ", pm);
+    }
+  }
   dprintf(tc_client_in[1], "%s\n", msg);
   // Don't print commands
   if(!strcmp(msg, "/help") ||
@@ -733,14 +793,36 @@ void sendmessage(GtkEntry* entry, struct viddata* data)
     gtk_entry_set_text(entry, "");
     return;
   }
+  if(!strncmp(msg, "/msg ", 5))
+  {
+    const char* end=strchr(&msg[5], ' ');
+    if(end)
+    {
+      pm=strndup(&msg[5], end-&msg[5]);
+      struct user* user=finduser(pm);
+      if(!user)
+      {
+        gtk_entry_set_text(entry, "");
+        printchat("No such user", 0);
+        free(pm);
+        return;
+      }
+    }
+  }
   char text[strlen("[00:00] ")+strlen(nickname)+strlen(": ")+strlen(msg)+1];
   time_t timestamp=time(0);
   struct tm* t=localtime(&timestamp);
   sprintf(text, "[%02i:%02i] ", t->tm_hour, t->tm_min);
-  sprintf(&text[8], "%s: %s", nickname, msg);
-  if(config_get_bool("enable_logging")){logger_write(text, channel, 0);}
-  printchat_color(data, text, mycolor, 8);
+  if(pm && msg[0]=='/')
+  {
+    sprintf(&text[8], "%s: %s", nickname, &msg[6+strlen(pm)]);
+  }else{
+    sprintf(&text[8], "%s: %s", nickname, msg);
+  }
+  if(config_get_bool("enable_logging")){logger_write(text, channel, pm);}
+  printchat_color(text, mycolor, 8, pm);
   gtk_entry_set_text(entry, "");
+  free(pm);
 }
 
 void startsession(GtkButton* button, void* x)
@@ -899,23 +981,7 @@ int main(int argc, char** argv)
   data->scroll=gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(gtk_builder_get_object(gui, "chatscroll")));
 
   data->buffer=gtk_text_view_get_buffer(GTK_TEXT_VIEW(chatview));
-  #define colormap(code, color) gtk_text_buffer_create_tag(data->buffer, code, "foreground", color, (char*)0)
-  colormap("[31", "#821615");
-  colormap("[31;1", "#c53332");
-  colormap("[33", "#a08f23");
-  //colormap("[33", "#a78901");
-  colormap("[33;1", "#919104");
-  colormap("[32;1", "#7bb224");
-  //colormap("[32;1", "#7db257");
-  colormap("[32", "#487d21");
-  colormap("[36", "#00a990");
-  colormap("[34;1", "#32a5d9");
-  //colormap("[34;1", "#1d82eb");
-  colormap("[34", "#1965b6");
-  colormap("[35", "#5c1a7a");
-  colormap("[35;1", "#9d5bb5");
-  //colormap("[35;1", "#c356a3");
-  //colormap("[35;1", "#b9807f");
+  buffer_setup_colors(data->buffer);
 
   GtkWidget* panes=GTK_WIDGET(gtk_builder_get_object(gui, "vpaned"));
   g_signal_connect(panes, "notify::position", G_CALLBACK(handleresizepane), data);
@@ -954,6 +1020,9 @@ int main(int argc, char** argv)
   item=GTK_WIDGET(gtk_builder_get_object(gui, "start_menu_add"));
   struct channelopts cc_add={-1,1};
   g_signal_connect(item, "activate", G_CALLBACK(channeldialog), &cc_add);
+  // Connect signal for tab changing (to un-highlight)
+  item=GTK_WIDGET(gtk_builder_get_object(gui, "tabs"));
+  g_signal_connect(item, "switch-page", G_CALLBACK(pm_select), 0);
   // Populate saved channels
   GtkWidget* startbox=GTK_WIDGET(gtk_builder_get_object(gui, "startbox"));
   int channelcount=config_get_int("channelcount");
diff --git a/utilities/gtk/gui.c b/utilities/gtk/gui.c
index 0b8dbfd..867b025 100644
--- a/utilities/gtk/gui.c
+++ b/utilities/gtk/gui.c
@@ -22,6 +22,7 @@
 #include "config.h"
 #include "logging.h"
 #include "compat.h"
+#include "userlist.h"
 
 extern void startsession(GtkButton* button, void* x);
 GtkBuilder* gui;
@@ -299,3 +300,99 @@ void channeldialog(GtkButton* button, struct channelopts* opts)
     g_signal_connect(obj, "clicked", G_CALLBACK(deletechannel), (void*)(intptr_t)opts->channel_id);
   }
 }
+
+void pm_close(GtkButton* btn, GtkWidget* tab)
+{
+  gtk_widget_destroy(tab);
+  struct user* user=user_find_by_tab(tab);
+  if(!user){return;}
+  user->pm_tab=0;
+  user->pm_tablabel=0;
+  user->pm_buffer=0;
+  user->pm_scroll=0;
+  user->pm_highlight=0;
+}
+
+void pm_open(const char* nick, char select)
+{
+  struct user* user=finduser(nick);
+  if(!user){return;}
+  GtkNotebook* tabs=GTK_NOTEBOOK(gtk_builder_get_object(gui, "tabs"));
+  if(user->pm_tab)
+  {
+    if(!select){return;}
+    int num=gtk_notebook_page_num(tabs, user->pm_tab);
+    gtk_notebook_set_current_page(tabs, num);
+    return;
+  }
+  user->pm_tab=gtk_text_view_new();
+  user->pm_buffer=gtk_text_view_get_buffer(GTK_TEXT_VIEW(user->pm_tab));
+  user->pm_scroll=gtk_scrollable_get_vadjustment(GTK_SCROLLABLE(user->pm_tab));
+  user->pm_tablabel=gtk_label_new(nick);
+  buffer_setup_colors(user->pm_buffer);
+  GtkWidget* tabbox=gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
+#if GTK_MAJOR_VERSION<3 || (GTK_MAJOR_VERSION==3 && GTK_MINOR_VERSION<10)
+  GtkWidget* closebtn=gtk_button_new_from_icon_name("gtk-close", GTK_ICON_SIZE_BUTTON);
+#else
+  GtkWidget* closebtn=gtk_button_new_from_icon_name("window-close", GTK_ICON_SIZE_BUTTON);
+#endif
+  g_signal_connect(closebtn, "clicked", G_CALLBACK(pm_close), user->pm_tab);
+  gtk_box_pack_start(GTK_BOX(tabbox), user->pm_tablabel, 1, 1, 0);
+  gtk_box_pack_start(GTK_BOX(tabbox), closebtn, 0, 0, 0);
+  int num=gtk_notebook_append_page(tabs, user->pm_tab, tabbox);
+  gtk_text_view_set_editable(GTK_TEXT_VIEW(user->pm_tab), 0);
+  gtk_text_view_set_cursor_visible(GTK_TEXT_VIEW(user->pm_tab), 0);
+  gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(user->pm_tab), GTK_WRAP_CHAR);
+  gtk_widget_show_all(user->pm_tab);
+  gtk_widget_show_all(tabbox);
+  if(select)
+  {
+    gtk_notebook_set_current_page(tabs, num);
+  }
+}
+
+void pm_highlight(const char* nick)
+{
+  if(!nick){return;}
+  struct user* user=finduser(nick);
+  if(!user || !user->pm_tablabel){return;}
+  // Only highlight tabs we're not on
+  GtkNotebook* tabs=GTK_NOTEBOOK(gtk_builder_get_object(gui, "tabs"));
+  GtkWidget* page=gtk_notebook_get_nth_page(tabs, gtk_notebook_get_current_page(tabs));
+  if(page==user->pm_tab){return;}
+  char* markup=g_markup_printf_escaped("<span color=\"red\">%s</span>", user->nick);
+  gtk_label_set_markup(GTK_LABEL(user->pm_tablabel), markup);
+  g_free(markup);
+  user->pm_highlight=1;
+}
+
+char pm_select(GtkNotebook* tabs, GtkWidget* tab, int num, void* x)
+{
+  struct user* user=user_find_by_tab(tab);
+  if(!user){return 0;}
+  // Reset highlighting
+  gtk_label_set_text(GTK_LABEL(user->pm_tablabel), user->nick);
+  user->pm_highlight=0;
+  return 0;
+}
+
+void buffer_setup_colors(GtkTextBuffer* buffer)
+{
+  #define colormap(code, color) gtk_text_buffer_create_tag(buffer, code, "foreground", color, (char*)0)
+  colormap("[31", "#821615");
+  colormap("[31;1", "#c53332");
+  colormap("[33", "#a08f23");
+  //colormap("[33", "#a78901");
+  colormap("[33;1", "#919104");
+  colormap("[32;1", "#7bb224");
+  //colormap("[32;1", "#7db257");
+  colormap("[32", "#487d21");
+  colormap("[36", "#00a990");
+  colormap("[34;1", "#32a5d9");
+  //colormap("[34;1", "#1d82eb");
+  colormap("[34", "#1965b6");
+  colormap("[35", "#5c1a7a");
+  colormap("[35;1", "#9d5bb5");
+  //colormap("[35;1", "#c356a3");
+  //colormap("[35;1", "#b9807f");
+}
diff --git a/utilities/gtk/gui.h b/utilities/gtk/gui.h
index 971d680..aa646b2 100644
--- a/utilities/gtk/gui.h
+++ b/utilities/gtk/gui.h
@@ -32,5 +32,9 @@ extern void toggle_logging(GtkToggleButton* button, GtkBuilder* gui);
 extern void toggle_youtubecmd(GtkToggleButton* button, GtkBuilder* gui);
 extern void deletechannel(GtkButton* button, void* x);
 extern void channeldialog(GtkButton* button, struct channelopts* opts);
+extern void pm_open(const char* nick, char select);
+extern void pm_highlight(const char* nick);
+extern char pm_select(GtkNotebook* tabs, GtkWidget* tab, int num, void* x);
+extern void buffer_setup_colors(GtkTextBuffer* buffer);
 
 extern GtkBuilder* gui;
diff --git a/utilities/gtk/userlist.c b/utilities/gtk/userlist.c
index 89d1df5..0aac809 100644
--- a/utilities/gtk/userlist.c
+++ b/utilities/gtk/userlist.c
@@ -17,6 +17,7 @@
 #include <stdlib.h>
 #include <string.h>
 #include <gtk/gtk.h>
+#include "gui.h"
 #include "userlist.h"
 
 struct user* userlist=0;
@@ -33,6 +34,16 @@ struct user* finduser(const char* nick)
   return 0;
 }
 
+struct user* user_find_by_tab(GtkWidget* tab)
+{
+  unsigned int i;
+  for(i=0; i<usercount; ++i)
+  {
+    if(userlist[i].pm_tab==tab){return &userlist[i];}
+  }
+  return 0;
+}
+
 struct user* adduser(const char* nick)
 {
   struct user* user=finduser(nick);
@@ -41,6 +52,11 @@ struct user* adduser(const char* nick)
   userlist=realloc(userlist, sizeof(struct user)*usercount);
   userlist[usercount-1].nick=strdup(nick);
   userlist[usercount-1].label=gtk_label_new(nick); // TODO: some kind of menubutton for actions?
+  userlist[usercount-1].pm_tab=0;
+  userlist[usercount-1].pm_tablabel=0;
+  userlist[usercount-1].pm_buffer=0;
+  userlist[usercount-1].pm_scroll=0;
+  userlist[usercount-1].pm_highlight=0;
 #if GTK_MAJOR_VERSION>=3
   gtk_widget_set_halign(userlist[usercount-1].label, GTK_ALIGN_START);
 #endif
@@ -65,6 +81,15 @@ void renameuser(const char* old, const char* newnick)
   }else{
     gtk_label_set_text(GTK_LABEL(user->label), newnick);
   }
+  if(user->pm_tablabel)
+  {
+    if(user->pm_highlight)
+    {
+      pm_highlight(newnick);
+    }else{
+      gtk_label_set_text(GTK_LABEL(user->pm_tablabel), newnick);
+    }
+  }
 }
 
 void removeuser(const char* nick)
diff --git a/utilities/gtk/userlist.h b/utilities/gtk/userlist.h
index 98b886e..eb229c6 100644
--- a/utilities/gtk/userlist.h
+++ b/utilities/gtk/userlist.h
@@ -20,6 +20,11 @@ struct user
   GtkWidget* label;
 //  unsigned int id; // hm, tc_client doesn't share IDs other than in guestnicks, this might be useful for a ban-after-they-left situation
   char ismod;
+  GtkWidget* pm_tab;
+  GtkWidget* pm_tablabel;
+  GtkTextBuffer* pm_buffer;
+  GtkAdjustment* pm_scroll;
+  char pm_highlight;
 };
 
 extern struct user* userlist;
@@ -27,6 +32,7 @@ extern unsigned int usercount;
 extern GtkWidget* userlistwidget;
 
 extern struct user* finduser(const char* nick);
+extern struct user* user_find_by_tab(GtkWidget* tab);
 extern struct user* adduser(const char* nick);
 extern void renameuser(const char* old, const char* newnick);
 extern void removeuser(const char* nick);