sup

A curses threads-with-tags style email client

sup.git

git clone https://supmua.dev/git/sup/
commit 9478114b592e0ba7917a65804aacd401d21d70c2
parent 7ab166131aa760740dac97333f04a79966cb5fc2
Author: William Morgan <wmorgan-sup@masanjin.net>
Date:   Thu, 28 May 2009 10:45:06 -0400

Merge commit 'origin/undo-manager'

Conflicts:

	lib/sup/modes/thread-index-mode.rb

Diffstat:
M Manifest.txt | 1 +
M README.txt | 2 +-
M lib/sup.rb | 2 ++
M lib/sup/modes/inbox-mode.rb | 34 ++++++++++++++++++++++++++++++++++
M lib/sup/modes/thread-index-mode.rb | 143 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
A lib/sup/undo.rb | 39 +++++++++++++++++++++++++++++++++++++++
6 files changed, 195 insertions(+), 26 deletions(-)
diff --git a/Manifest.txt b/Manifest.txt
@@ -71,5 +71,6 @@ lib/sup/suicide.rb
 lib/sup/tagger.rb
 lib/sup/textfield.rb
 lib/sup/thread.rb
+lib/sup/undo.rb
 lib/sup/update.rb
 lib/sup/util.rb
diff --git a/README.txt b/README.txt
@@ -80,7 +80,7 @@ Current limitations which will be fixed:
 - Unix-centrism in MIME attachment handling and in sendmail
   invocation.
 
-- Several obvious missing features, like undo, filters / saved
+- Several obvious missing features, like filters / saved
   searches, message annotations, etc.
 
 == SYNOPSYS:
diff --git a/lib/sup.rb b/lib/sup.rb
@@ -110,6 +110,7 @@ module Redwood
     Redwood::PollManager.new
     Redwood::SuicideManager.new Redwood::SUICIDE_FN
     Redwood::CryptoManager.new
+    Redwood::UndoManager.new
   end
 
   def finish
@@ -266,6 +267,7 @@ require "sup/tagger"
 require "sup/draft"
 require "sup/poll"
 require "sup/crypto"
+require "sup/undo"
 require "sup/horizontal-selector"
 require "sup/modes/line-cursor-mode"
 require "sup/modes/help-mode"
diff --git a/lib/sup/modes/inbox-mode.rb b/lib/sup/modes/inbox-mode.rb
@@ -26,12 +26,27 @@ class InboxMode < ThreadIndexMode
 
   def archive
     return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    UndoManager.register "archiving thread" do
+      thread.apply_label :inbox
+      add_or_unhide thread.first
+    end
+
     cursor_thread.remove_label :inbox
     hide_thread cursor_thread
     regen_text
   end
 
   def multi_archive threads
+    UndoManager.register "archiving #{threads.size.pluralize 'thread'}" do
+      threads.map do |t|
+        t.apply_label :inbox
+        add_or_unhide t.first
+      end
+      regen_text
+    end
+
     threads.each do |t|
       t.remove_label :inbox
       hide_thread t
@@ -41,6 +56,14 @@ class InboxMode < ThreadIndexMode
 
   def read_and_archive
     return unless cursor_thread
+    thread = cursor_thread # to make sure lambda only knows about 'old' cursor_thread
+
+    UndoManager.register "reading and archiving thread" do
+      thread.apply_label :inbox
+      thread.apply_label :unread
+      add_or_unhide thread.first
+    end
+
     cursor_thread.remove_label :unread
     cursor_thread.remove_label :inbox
     hide_thread cursor_thread
@@ -48,12 +71,23 @@ class InboxMode < ThreadIndexMode
   end
 
   def multi_read_and_archive threads
+    old_labels = threads.map { |t| t.labels.dup }
+
     threads.each do |t|
       t.remove_label :unread
       t.remove_label :inbox
       hide_thread t
     end
     regen_text
+
+    UndoManager.register "reading and archiving #{threads.size.pluralize 'thread'}" do
+      threads.zip(old_labels).each do |t, l|
+        t.labels = l
+        add_or_unhide t.first
+      end
+      regen_text
+    end
+
   end
 
   def handle_unarchived_update sender, m
diff --git a/lib/sup/modes/thread-index-mode.rb b/lib/sup/modes/thread-index-mode.rb
@@ -44,6 +44,7 @@ EOS
     k.add :tag_matching, "Tag matching threads", 'g'
     k.add :apply_to_tagged, "Apply next command to all tagged threads", '+', '='
     k.add :join_threads, "Force tagged threads to be joined into the same thread", '#'
+    k.add :undo, "Undo the previous action", 'u'
   end
 
   def initialize hidden_labels=[], load_thread_opts={}
@@ -86,6 +87,7 @@ EOS
 
   def reload
     drop_all_threads
+    UndoManager.clear
     BufferManager.draw_screen
     load_threads :num => buffer.content_height
   end
@@ -211,6 +213,10 @@ EOS
     add_or_unhide m
   end
 
+  def undo
+    UndoManager.undo
+  end
+
   def update
     @mutex.synchronize do
       ## let's see you do THIS in python
@@ -233,66 +239,122 @@ EOS
     end
   end
 
+  ## returns an undo lambda
   def actually_toggle_starred t
+    pos = curpos
     if t.has_label? :starred # if ANY message has a star
       t.remove_label :starred # remove from all
       UpdateManager.relay self, :unstarred, t.first
+      lambda do
+        t.first.add_label :starred
+        UpdateManager.relay self, :starred, t.first
+        regen_text
+      end
     else
       t.first.add_label :starred # add only to first
       UpdateManager.relay self, :starred, t.first
+      lambda do
+        t.remove_label :starred
+        UpdateManager.relay self, :unstarred, t.first
+        regen_text
+      end
     end
   end  
 
   def toggle_starred 
     t = cursor_thread or return
-    actually_toggle_starred t
+    undo = actually_toggle_starred t
+    UndoManager.register "toggling thread starred status", undo
     update_text_for_line curpos
     cursor_down
   end
 
   def multi_toggle_starred threads
-    threads.each { |t| actually_toggle_starred t }
+    UndoManager.register "toggling #{threads.size.pluralize 'thread'} starred status",
+      threads.map { |t| actually_toggle_starred t }
     regen_text
   end
 
+  ## returns an undo lambda
   def actually_toggle_archived t
+    thread = t
+    pos = curpos
     if t.has_label? :inbox
       t.remove_label :inbox
       UpdateManager.relay self, :archived, t.first
+      lambda do
+        thread.apply_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self,:unarchived, thread.first
+      end
     else
       t.apply_label :inbox
       UpdateManager.relay self, :unarchived, t.first
+      lambda do
+        thread.remove_label :inbox
+        update_text_for_line pos
+        UpdateManager.relay self, :unarchived, thread.first
+      end
     end
   end
 
+  ## returns an undo lambda
   def actually_toggle_spammed t
+    thread = t
     if t.has_label? :spam
       t.remove_label :spam
+      add_or_unhide t.first
       UpdateManager.relay self, :unspammed, t.first
+      lambda do
+        thread.apply_label :spam
+        self.hide_thread thread
+        UpdateManager.relay self,:spammed, thread.first
+      end
     else
       t.apply_label :spam
+      hide_thread t
       UpdateManager.relay self, :spammed, t.first
+      lambda do
+        thread.remove_label :spam
+        add_or_unhide thread.first
+        UpdateManager.relay self,:unspammed, thread.first
+      end
     end
   end
 
+  ## returns an undo lambda
   def actually_toggle_deleted t
     if t.has_label? :deleted
       t.remove_label :deleted
+      add_or_unhide t.first
       UpdateManager.relay self, :undeleted, t.first
+      lambda do
+        t.apply_label :deleted
+        hide_thread t
+        UpdateManager.relay self, :deleted, t.first
+      end
     else
       t.apply_label :deleted
+      hide_thread t
       UpdateManager.relay self, :deleted, t.first
+      lambda do
+        t.remove_label :deleted
+        add_or_unhide t.first
+        UpdateManager.relay self, :undeleted, t.first
+      end
     end
   end
 
   def toggle_archived 
     t = cursor_thread or return
-    actually_toggle_archived t
+    undo = actually_toggle_archived t
+    UndoManager.register "deleting/undeleting thread #{t.first.id}", undo, lambda { update_text_for_line curpos }
     update_text_for_line curpos
   end
 
   def multi_toggle_archived threads
-    threads.each { |t| actually_toggle_archived t }
+    undos = threads.map { |t| actually_toggle_archived t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}", undos, lambda { regen_text }
     regen_text
   end
 
@@ -353,10 +415,9 @@ EOS
   ## see deleted or spam emails, and when you undelete or unspam them
   ## you also want them to disappear immediately.
   def multi_toggle_spam threads
-    threads.each do |t|
-      actually_toggle_spammed t
-      hide_thread t 
-    end
+    undos = threads.map { |t| actually_toggle_spammed t }
+    UndoManager.register "marking/unmarking  #{threads.size.pluralize 'thread'} as spam",
+                         undos, lambda { regen_text }
     regen_text
   end
 
@@ -367,10 +428,9 @@ EOS
 
   ## see comment for multi_toggle_spam
   def multi_toggle_deleted threads
-    threads.each do |t|
-      actually_toggle_deleted t
-      hide_thread t 
-    end
+    undos = threads.map { |t| actually_toggle_deleted t }
+    UndoManager.register "deleting/undeleting #{threads.size.pluralize 'thread'}",
+                         undos, lambda { regen_text }
     regen_text
   end
 
@@ -379,13 +439,23 @@ EOS
     multi_kill [t]
   end
 
+  ## m-m-m-m-MULTI-KILL
   def multi_kill threads
+    UndoManager.register "killing #{threads.size.pluralize 'thread'}" do
+      threads.each do |t|
+        t.remove_label :killed
+        add_or_unhide t.first
+      end
+      regen_text
+    end
+
     threads.each do |t|
       t.apply_label :killed
       hide_thread t
     end
+
     regen_text
-    BufferManager.flash "#{threads.size.pluralize 'Thread'} killed."
+    BufferManager.flash "#{threads.size.pluralize 'thread'} killed."
   end
 
   def save background=true
@@ -454,6 +524,10 @@ EOS
   def edit_labels
     thread = cursor_thread or return
     speciall = (@hidden_labels + LabelManager::RESERVED_LABELS).uniq
+
+    old_labels = thread.labels
+    pos = curpos
+
     keepl, modifyl = thread.labels.partition { |t| speciall.member? t }
 
     user_labels = BufferManager.ask_for_labels :label, "Labels for thread: ", modifyl, @hidden_labels
@@ -462,6 +536,13 @@ EOS
     thread.labels = keepl + user_labels
     user_labels.each { |l| LabelManager << l }
     update_text_for_line curpos
+
+    UndoManager.register "labeling thread #{thread.first.id}" do
+      thread.labels = old_labels
+      update_text_for_line pos
+      UpdateManager.relay self, :labeled, thread.first
+    end
+
     UpdateManager.relay self, :labeled, thread.first
   end
 
@@ -471,21 +552,33 @@ EOS
 
     user_labels.map! { |l| (l.to_s =~ /^-/)? [l.to_s.gsub(/^-?/, '').to_sym, true] : [l, false] }
     hl = user_labels.select { |(l,_)| @hidden_labels.member? l }
-    if hl.empty?
-      threads.each do |t|
-        user_labels.each do |(l, to_remove)|
-          if to_remove
-            t.remove_label l
-          else
-            t.apply_label l
-          end
+    unless hl.empty?
+      BufferManager.flash "'#{hl}' is a reserved label!"
+      return
+    end
+
+    old_labels = threads.map { |t| t.labels.dup }
+
+    threads.each do |t|
+      user_labels.each do |(l, to_remove)|
+        if to_remove
+          t.remove_label l
+        else
+          t.apply_label l
+          LabelManager << l
         end
       end
-      user_labels.each { |(l,_)| LabelManager << l }
-    else
-      BufferManager.flash "'#{hl}' is a reserved label!"
     end
+
     regen_text
+
+    UndoManager.register "labeling #{threads.size.pluralize 'thread'}" do
+      threads.zip(old_labels).map do |t, old_labels|
+        t.labels = old_labels
+        UpdateManager.relay self, :labeled, t.first
+      end
+      regen_text
+    end
   end
 
   def reply
@@ -689,7 +782,7 @@ protected
 
     date = t.date.to_nice_s
 
-    starred = t.has_label?(:starred)
+    starred = t.has_label? :starred
 
     ## format the from column
     cur_width = 0
diff --git a/lib/sup/undo.rb b/lib/sup/undo.rb
@@ -0,0 +1,39 @@
+module Redwood
+
+## Implements a single undo list for the Sup instance
+##
+## The basic idea is to keep a list of lambdas to undo
+## things. When an action is called (such as 'archive'),
+## a lambda is registered with UndoManager that will
+## undo the archival action
+
+class UndoManager
+  include Singleton
+
+  def initialize
+    @@actionlist = []
+    self.class.i_am_the_instance self
+  end
+
+  def register desc, *actions, &b
+    actions = [*actions.flatten]
+    actions << b if b
+    raise ArgumentError, "need at least one action" unless actions.length > 0
+    @@actionlist.push :desc => desc, :actions => actions
+  end
+
+  def undo
+    unless @@actionlist.empty?
+      actionset = @@actionlist.pop
+      actionset[:actions].each { |action| action.call }
+      BufferManager.flash "undid #{actionset[:desc]}"
+    else
+      BufferManager.flash "nothing more to undo!"
+    end
+  end
+
+  def clear
+    @@actionlist = []
+  end
+end
+end