xmpedit

GTK+ editor for XMP metadata embedded in images
git clone https://code.djc.id.au/git/xmpedit/
commit 002f421a8da7ff536e8f5aa9c6f38db8c3a4ecc1
parent 1a61e2c49e69a7b49c64515a2978e4cfb42a3dca
Author: Dan Callaghan <djc@djc.id.au>
Date:   Sun, 19 Sep 2010 19:39:24 +1000

explicit save button, with dirty tracking and close-without-saving warning dialog (TODO implement revert)

Diffstat:
Msrc/ImageMetadata.vala | 12+++++++++++-
Msrc/MainWindow.vala | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtest/guitest.py | 34++++++++++++++++++++++++++++------
3 files changed, 121 insertions(+), 14 deletions(-)
diff --git a/src/ImageMetadata.vala b/src/ImageMetadata.vala
@@ -15,6 +15,7 @@ public interface PropertyEditor : Gtk.Widget {
     public abstract string prop_name { get; }
     public abstract RDF.Graph graph { get; set; }
     public abstract RDF.URIRef subject { get; set; }
+    public signal void committed();
     
     /**
      * Examine the graph and return a one-line summary of the value found
@@ -136,6 +137,7 @@ private class Description : Gtk.Table, PropertyEditor {
                 object = new RDF.PlainLiteral(value);
             graph.insert(new RDF.Statement(subject, DC_DESCRIPTION, object));
         }
+        committed();
     }
 
 }
@@ -166,6 +168,7 @@ public class ImageMetadata : Object, Gtk.TreeModel {
 
     public string path { get; construct; }
     public Gee.List<PropertyEditor> properties { get; construct; }
+    public bool dirty = false;
     private Exiv2.Image image;
     private size_t xmp_packet_size;
     private RDF.Graph graph;
@@ -211,17 +214,22 @@ public class ImageMetadata : Object, Gtk.TreeModel {
             var pe = (PropertyEditor) Object.new(type);
             pe.graph = graph;
             pe.subject = subject;
+            pe.committed.connect(() => {
+                dirty = true;
+                updated();
+            });
             properties.add(pe);
         }
         //foreach (var tag in exiv_metadata.get_xmp_tags()) {
         //    properties.add(new PropertyEditor(tag, exiv_metadata.get_xmp_tag_string(tag)));
         //}
+        dirty = false;
         updated();
     }
     
     public void save() {
         return_if_fail(image != null); // only call after successful loading
-        // XXX shouldn't write if not dirty!
+        return_if_fail(dirty); // only call if dirty
 #if DEBUG
         stderr.puts("=== Final RDF graph:\n");
         foreach (var s in graph.get_statements())
@@ -247,6 +255,8 @@ public class ImageMetadata : Object, Gtk.TreeModel {
 #endif
         image.xmp_packet = xml.str;
         image.write_metadata();
+        dirty = false;
+        updated();
     }
     
     /****** TREEMODEL IMPLEMENTATION STUFF **********/
diff --git a/src/MainWindow.vala b/src/MainWindow.vala
@@ -6,21 +6,27 @@
 
 namespace Xmpedit {
 
+private const string STOCK_CLOSE_WITHOUT_SAVING = "xmpedit-close-without-saving";
+
 public class MainWindow : Gtk.Window {
 
+    private File file;
     private ImageMetadata image_metadata;
     private Gtk.Table table;
     private Gtk.Image image_preview;
-    private Gtk.ScrolledWindow tree_view_scrolled;
     private MetadataTreeView tree_view;
     private PropertyDetailView detail_view;
     
+    static construct {
+        add_stock();
+    }
+    
     public MainWindow(string path) throws GLib.Error {
         Object(type: Gtk.WindowType.TOPLEVEL);
+        file = File.new_for_path(path);
         image_metadata = new ImageMetadata(path);
         image_metadata.load();
         table = new Gtk.Table(/* rows */ 2, /* cols */ 2, /* homogeneous */ false);
-        var file = File.new_for_path(path);
         
         title = file.get_basename();
         default_width = 640;
@@ -42,10 +48,33 @@ public class MainWindow : Gtk.Window {
         tree_view = new MetadataTreeView.connected_to(image_metadata);
         ((Atk.Object) tree_view.get_accessible())
                 .set_name("Image properties");
-        tree_view_scrolled = new Gtk.ScrolledWindow(null, null);
+        var tree_view_scrolled = new Gtk.ScrolledWindow(null, null);
         tree_view_scrolled.add(tree_view);
         tree_view_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC);
-        table.attach(tree_view_scrolled,
+        var revert_button = new Gtk.Button.from_stock(Gtk.STOCK_REVERT_TO_SAVED);
+        revert_button.clicked.connect(() => {
+            critical("IMPLEMENTME");
+        });
+        var save_button = new Gtk.Button.from_stock(Gtk.STOCK_SAVE);
+        save_button.clicked.connect(() => {
+            image_metadata.save();
+        });
+        revert_button.sensitive = false;
+        save_button.sensitive = false;
+        image_metadata.updated.connect(() => {
+            revert_button.sensitive = image_metadata.dirty;
+            save_button.sensitive = image_metadata.dirty;
+        });
+        var left_button_box = new Gtk.HButtonBox();
+        left_button_box.spacing = 5;
+        left_button_box.layout_style = Gtk.ButtonBoxStyle.END;
+        left_button_box.add(revert_button);
+        left_button_box.add(save_button);
+        var left_vbox = new Gtk.VBox(/* homogeneous */ false, /* spacing */ 0);
+        left_vbox.pack_start(tree_view_scrolled, /* expand */ true, /* fill */ true);
+        left_vbox.pack_start(left_button_box, /* expand */ false, /* fill */ false,
+                /* padding */ 5);
+        table.attach(left_vbox,
                 0, 1, 0, 2,
                 Gtk.AttachOptions.FILL, Gtk.AttachOptions.FILL | Gtk.AttachOptions.EXPAND,
                 0, 0);
@@ -60,11 +89,57 @@ public class MainWindow : Gtk.Window {
         add(table);
         show_all();
         
-        destroy.connect(() => {
-            image_metadata.save();
+        delete_event.connect(() => {
+            if (image_metadata.dirty) {
+                var dialog = new Gtk.MessageDialog.with_markup(
+                        /* parent */ this, Gtk.DialogFlags.MODAL,
+                        Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE,
+                        "<big><b>Your changes to image \"%s\" have not been saved.</b></big>\n\n" +
+                        "Save changes before closing?",
+                        file.get_basename());
+                dialog.add_button(STOCK_CLOSE_WITHOUT_SAVING, 1);
+                dialog.add_button(Gtk.STOCK_CANCEL, 2);
+                dialog.add_button(Gtk.STOCK_SAVE, 3);
+                dialog.set_default_response(3);
+                var response = dialog.run();
+                dialog.destroy();
+                switch (response) {
+                    case 3: // save
+                        image_metadata.save();
+                        return false;
+                    case 1: // close
+                        return false;
+                    case 2: // cancel
+                    default:
+                        return true;
+                }
+            }
+            return false;
         });
+        
+        add_stock();
     }
-
+    
+    private static Gtk.StockItem[] STOCK = {
+        Gtk.StockItem() {
+            stock_id = STOCK_CLOSE_WITHOUT_SAVING,
+            label = "Close _without saving",
+            modifier = 0,
+            keyval = 0,
+            translation_domain = null
+        }
+    };
+    
+    /** Create custom stock entries used in the application. */
+    private static void add_stock() {
+        Gtk.stock_add_static(STOCK);
+    
+        var icon_factory = new Gtk.IconFactory();
+        icon_factory.add(STOCK_CLOSE_WITHOUT_SAVING,
+                Gtk.IconFactory.lookup_default(Gtk.STOCK_CLOSE));
+        icon_factory.add_default();
+    }
+    
 }
 
 }
diff --git a/test/guitest.py b/test/guitest.py
@@ -24,6 +24,7 @@ class XmpeditTestCase(unittest.TestCase):
         self.tempfile = tempfile.NamedTemporaryFile()
         self.tempfile.write(open(os.path.join('testdata', image_filename)).read())
         self.tempfile.flush()
+        self.tempfile.seek(0)
         self.popen = subprocess.Popen(
                 [os.path.join('target', 'xmpedit'), self.tempfile.name])
 
@@ -40,6 +41,10 @@ class XmpeditTestCase(unittest.TestCase):
                 return
             time.sleep(1)
         assert False, 'Process did not end'
+    
+    def assert_image_unmodified(self, image_filename):
+        self.tempfile.seek(0)
+        assert self.tempfile.read() == open(os.path.join('testdata', image_filename)).read(), 'Image should be unmodified'
 
     def get_window(self):
         root = Xlib.display.Display().screen().root # XXX multiple screens?
@@ -65,16 +70,33 @@ class Test(XmpeditTestCase):
     def tearDown(self):
         self.stop()
         
-    def test_roundtrip(self):
+    def test_do_nothing(self):
+        xmpedit = dogtail.tree.Root().application('xmpedit')
+        window = xmpedit.child(roleName='frame')
+        self.close_window()
+        self.assert_stopped()
+        self.assert_image_unmodified('24-06-06_1449.jpg')
+    
+    def test_close_without_saving(self):
         xmpedit = dogtail.tree.Root().application('xmpedit')
         window = xmpedit.child(roleName='frame')
-        time.sleep(0.5)
+        pe, = [child for child in window.child('Image properties').children
+                if child.name.splitlines()[0] == 'Description'] # ugh
+        pe.select()
+        entry = window.child(roleName='ROLE_TEXT', label='Description')
+        entry.grabFocus()
+        entry.text = 'new description'
+        lang = window.child(roleName='ROLE_TEXT', label='Language:')
+        lang.text = 'en'
+        pe.grabFocus() # XXX DELETEME
         self.close_window()
+        alert = xmpedit.child(roleName='alert')
+        self.assertEquals(alert.child(roleName='label').name,
+                'Your changes to image "%s" have not been saved.\n\nSave changes before closing?'
+                % os.path.basename(self.tempfile.name))
+        alert.button('Close without saving').doAction('click')
         self.assert_stopped()
-        xmp = extract_xmp(self.tempfile.name)
-        self.assertEquals(len(xmp), 2675) # 
-        self.assertEquals(extract_xmp(self.tempfile.name),
-                u'''<?xpacket begin="\ufeff" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="xmpedit 0.0-dev"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about=""><Iptc4xmlCore:Location xmlns:Iptc4xmlCore="http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/">UQ St Lucia</Iptc4xmlCore:Location><dc:description xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Alt><rdf:li xml:lang="x-default">Edward Scissorhands stencil graffiti on the wall of John Hines building.</rdf:li></rdf:Alt></dc:description></rdf:Description></rdf:RDF></x:xmpmeta>''' + ' ' * 2079 + '''<?xpacket end="w"?>''')
+        self.assert_image_unmodified('24-06-06_1449.jpg')
 
 if __name__ == '__main__':
     unittest.main()