1# Copyright (C) 2008-2009 Kai Willadsen <kai.willadsen@gmail.com>
2#
3# This program is free software; you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation; either version 2 of the License, or
6# (at your option) any later version.
7#
8# This program is distributed in the hope that it will be useful,
9# but WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11# GNU General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program; if not, write to the Free Software
15# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
16# USA.
17
18import os
19import sys
20
21import glib
22import gio
23import gtk
24import gobject
25import pango
26import atk
27# gconf is also imported; see end of HistoryEntry class for details
28from gettext import gettext as _
29
30from ..util.compat import text_type
31
32# This file is a Python translation of:
33#  * gedit/gedit/gedit-history-entry.c
34#  * libgnomeui/libgnomeui/gnome-file-entry.c
35# roughly based on Colin Walters' Python translation of msgarea.py from Hotwire
36
37MIN_ITEM_LEN = 3
38HISTORY_ENTRY_HISTORY_LENGTH_DEFAULT = 10
39
40
41def _remove_item(store, text):
42    if text is None:
43        return False
44
45    for row in store:
46        if row[0] == text:
47            store.remove(row.iter)
48            return True
49    return False
50
51
52def _clamp_list_store(liststore, max_items):
53    try:
54        # -1 because TreePath counts from 0
55        it = liststore.get_iter(max_items - 1)
56    except ValueError:
57        return
58    valid = True
59    while valid:
60        valid = liststore.remove(it)
61
62
63def _escape_cell_data_func(col, renderer, model, it, escape_func):
64    string = model.get(it, 0)
65    escaped = escape_func(string)
66    renderer.set("text", escaped)
67
68
69class HistoryWidget(object):
70
71    def __init__(self, history_id=None, enable_completion=False, **kwargs):
72        self._history_id = history_id
73        self._history_length = HISTORY_ENTRY_HISTORY_LENGTH_DEFAULT
74        self._completion = None
75        self._get_gconf_client()
76
77        self.set_model(gtk.ListStore(str))
78        self.set_enable_completion(enable_completion)
79
80    def do_get_property(self, pspec):
81        if pspec.name == "history-id":
82            return self._history_id
83        else:
84            raise AttributeError("Unknown property: %s" % pspec.name)
85
86    def do_set_property(self, pspec, value):
87        if pspec.name == "history-id":
88            # FIXME: if we change history-id after our store is populated, odd
89            # things might happen
90            store = self.get_model()
91            store.clear()
92            self._history_id = value
93            self._load_history()
94        else:
95            raise AttributeError("Unknown property: %s" % pspec.name)
96
97    def _get_gconf_client(self):
98        self._gconf_client = gconf.client_get_default()
99
100    def _get_history_key(self):
101        # We store data under /apps/gnome-settings/ like GnomeEntry did.
102        if not self._history_id:
103            return None
104        key = ''.join(["/apps/gnome-settings/", "meld", "/history-",
105                       gconf.escape_key(self._history_id, -1)])
106        return key
107
108    def _save_history(self):
109        key = self._get_history_key()
110        if key is None:
111            return
112        gconf_items = [row[0] for row in self.get_model()]
113        self._gconf_client.set_list(key, gconf.VALUE_STRING, gconf_items)
114
115    def _insert_history_item(self, text, prepend):
116        if len(text) <= MIN_ITEM_LEN:
117            return
118
119        store = self.get_model()
120        if not _remove_item(store, text):
121            _clamp_list_store(store, self._history_length - 1)
122
123        if (prepend):
124            store.insert(0, (text,))
125        else:
126            store.append((text,))
127        self._save_history()
128
129    def prepend_history(self, text):
130        if not text:
131            return
132        self._insert_history_item(text, True)
133
134    def append_history(self, text):
135        if not text:
136            return
137        self._insert_history_item(text, False)
138
139    def _load_history(self):
140        key = self._get_history_key()
141        if key is None:
142            return
143        gconf_items = self._gconf_client.get_list(key, gconf.VALUE_STRING)
144
145        store = self.get_model()
146        store.clear()
147
148        for item in gconf_items[:self._history_length - 1]:
149            store.append((item,))
150
151    def clear(self):
152        store = self.get_model()
153        store.clear()
154        self._save_history()
155
156    def set_history_length(self, max_saved):
157        if max_saved <= 0:
158            return
159        self._history_length = max_saved
160        if len(self.get_model()) > max_saved:
161            self._load_history()
162
163    def get_history_length(self):
164        return self._history_length
165
166    def set_enable_completion(self, enable):
167        if enable:
168            if self._completion is not None:
169                return
170            self._completion = gtk.EntryCompletion()
171            self._completion.set_model(self.get_model())
172            self._completion.set_text_column(0)
173            self._completion.set_minimum_key_length(MIN_ITEM_LEN)
174            self._completion.set_popup_completion(False)
175            self._completion.set_inline_completion(True)
176            self.child.set_completion(self._completion)
177        else:
178            if self._completion is None:
179                return
180            self.get_entry().set_completion(None)
181            self._completion = None
182
183    def get_enable_completion(self):
184        return self._completion is not None
185
186    def get_entry(self):
187        return self.child
188
189    def focus_entry(self):
190        self.child.grab_focus()
191
192    def set_escape_func(self, escape_func):
193        cells = self.get_cells()
194        # We only have one cell renderer
195        if len(cells) == 0 or len(cells) > 1:
196            return
197
198        if escape_func is not None:
199            self.set_cell_data_func(cells[0], _escape_cell_data_func, escape_func)
200        else:
201            self.set_cell_data_func(cells[0], None, None)
202
203
204class HistoryCombo(gtk.ComboBox, HistoryWidget):
205    __gtype_name__ = "HistoryCombo"
206
207    __gproperties__ = {
208        "history-id": (str, "History ID",
209                       "Identifier associated with entry's history store",
210                       None, gobject.PARAM_READWRITE),
211    }
212
213    def __init__(self, history_id=None, **kwargs):
214        super(HistoryCombo, self).__init__(**kwargs)
215        HistoryWidget.__init__(self, history_id)
216        self.set_model(gtk.ListStore(str, str))
217        rentext = gtk.CellRendererText()
218        rentext.props.width_chars = 60
219        rentext.props.ellipsize = pango.ELLIPSIZE_END
220        self.pack_start(rentext, True)
221        self.set_attributes(rentext, text=0)
222
223    def _save_history(self):
224        key = self._get_history_key()
225        if key is None:
226            return
227        gconf_items = [row[1] for row in self.get_model()]
228        self._gconf_client.set_list(key, gconf.VALUE_STRING, gconf_items)
229
230    def _insert_history_item(self, text, prepend):
231        if len(text) <= MIN_ITEM_LEN:
232            return
233
234        # Redefining here to key off the full text, not the first line
235        def _remove_item(store, text):
236            if text is None:
237                return False
238
239            for row in store:
240                if row[1] == text:
241                    store.remove(row.iter)
242                    return True
243            return False
244
245        store = self.get_model()
246        if not _remove_item(store, text):
247            _clamp_list_store(store, self._history_length - 1)
248
249        row = (text.splitlines()[0], text)
250
251        if (prepend):
252            store.insert(0, row)
253        else:
254            store.append(row)
255        self._save_history()
256
257    def _load_history(self):
258        key = self._get_history_key()
259        if key is None:
260            return
261        gconf_items = self._gconf_client.get_list(key, gconf.VALUE_STRING)
262
263        store = self.get_model()
264        store.clear()
265
266        # This override is here to handle multi-line commit messages, and is
267        # specific to HistoryCombo use in VcView.
268        for item in gconf_items[:self._history_length - 1]:
269            firstline = item.splitlines()[0]
270            store.append((firstline, item))
271
272
273class HistoryEntry(gtk.ComboBoxEntry, HistoryWidget):
274    __gtype_name__ = "HistoryEntry"
275
276    __gproperties__ = {
277        "history-id": (str, "History ID",
278                       "Identifier associated with entry's history store",
279                       None, gobject.PARAM_READWRITE),
280    }
281
282    def __init__(self, history_id=None, enable_completion=False, **kwargs):
283        super(HistoryEntry, self).__init__(**kwargs)
284        HistoryWidget.__init__(self, history_id, enable_completion)
285        self.props.text_column = 0
286
287
288try:
289    import gconf
290    # Verify that gconf is actually working (bgo#666136)
291    client = gconf.client_get_default()
292    key = '/apps/meld/gconf-test'
293    client.set_int(key, os.getpid())
294    client.unset(key)
295except (ImportError, glib.GError):
296    do_nothing = lambda *args: None
297    for m in ('_save_history', '_load_history', '_get_gconf_client'):
298        setattr(HistoryWidget, m, do_nothing)
299        setattr(HistoryCombo, m, do_nothing)
300
301
302
303def _expand_filename(filename, default_dir):
304    if not filename:
305        return ""
306    if os.path.isabs(filename):
307        return filename
308    expanded = os.path.expanduser(filename)
309    if expanded != filename:
310        return expanded
311    elif default_dir:
312        return os.path.expanduser(os.path.join(default_dir, filename))
313    else:
314        return os.path.join(os.getcwd(), filename)
315
316
317last_open = {}
318
319
320class HistoryFileEntry(gtk.HBox, gtk.Editable):
321    __gtype_name__ = "HistoryFileEntry"
322
323    __gsignals__ = {
324        "browse_clicked" : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, []),
325        "activate" : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, []),
326        "changed" : (gobject.SIGNAL_RUN_LAST, gobject.TYPE_NONE, [])
327    }
328
329    __gproperties__ = {
330        "dialog-title":    (str, "Default path",
331                            "Default path for file chooser",
332                            "~", gobject.PARAM_READWRITE),
333        "default-path":    (str, "Default path",
334                            "Default path for file chooser",
335                            "~", gobject.PARAM_READWRITE),
336        "directory-entry": (bool, "File or directory entry",
337                            "Whether the created file chooser should select directories instead of files",
338                            False, gobject.PARAM_READWRITE),
339        "filename":        (str, "Filename",
340                            "Filename of the selected file",
341                            "", gobject.PARAM_READWRITE),
342        "history-id":      (str, "History ID",
343                            "Identifier associated with entry's history store",
344                            None, gobject.PARAM_READWRITE),
345        "modal":           (bool, "File chooser modality",
346                            "Whether the created file chooser is modal",
347                            False, gobject.PARAM_READWRITE),
348    }
349
350
351    def __init__(self, **kwargs):
352        super(HistoryFileEntry, self).__init__(**kwargs)
353
354        self.fsw = None
355        self.__browse_dialog_title = None
356        self.__filechooser_action = gtk.FILE_CHOOSER_ACTION_OPEN
357        self.__default_path = "~"
358        self.__directory_entry = False
359        self.__modal = False
360
361        self.set_spacing(3)
362
363        # TODO: completion would be nice, but some quirks make it currently too irritating to turn on by default
364        self.__gentry = HistoryEntry()
365        entry = self.__gentry.get_entry()
366        entry.connect("changed", lambda *args: self.emit("changed"))
367        entry.connect("activate", lambda *args: self.emit("activate"))
368
369        # We need to get rid of the pre-existing drop site on the entry
370        self.__gentry.get_entry().drag_dest_unset()
371        self.drag_dest_set(gtk.DEST_DEFAULT_MOTION |
372                           gtk.DEST_DEFAULT_HIGHLIGHT |
373                           gtk.DEST_DEFAULT_DROP,
374                           [], gtk.gdk.ACTION_COPY)
375        self.drag_dest_add_uri_targets()
376        self.connect("drag_data_received",
377                     self.history_entry_drag_data_received)
378
379        self.pack_start(self.__gentry, True, True, 0)
380        self.__gentry.show()
381
382        button = gtk.Button(_("_Browse..."))
383        button.connect("clicked", self.__browse_clicked)
384        self.pack_start(button, False, False, 0)
385        button.show()
386
387        access_entry = self.__gentry.get_accessible()
388        access_button = button.get_accessible()
389        if access_entry and access_button:
390            access_entry.set_name(_("Path"))
391            access_entry.set_description(_("Path to file"))
392            access_button.set_description(_("Pop up a file selector to choose a file"))
393            access_button.add_relationship(atk.RELATION_CONTROLLER_FOR, access_entry)
394            access_entry.add_relationship(atk.RELATION_CONTROLLED_BY, access_button)
395
396    def do_get_property(self, pspec):
397        if pspec.name == "dialog-title":
398            return self.__browse_dialog_title
399        elif pspec.name == "default-path":
400            return self.__default_path
401        elif pspec.name == "directory-entry":
402            return self.__directory_entry
403        elif pspec.name == "filename":
404            return self.get_full_path()
405        elif pspec.name == "history-id":
406            return self.__gentry.props.history_id
407        elif pspec.name == "modal":
408            return self.__modal
409        else:
410            raise AttributeError("Unknown property: %s" % pspec.name)
411
412    def do_set_property(self, pspec, value):
413        if pspec.name == "dialog-title":
414            self.__browse_dialog_title = value
415        elif pspec.name == "default-path":
416            if value:
417                self.__default_path = os.path.abspath(value)
418            else:
419                self.__default_path = None
420        elif pspec.name == "directory-entry":
421            self.__directory_entry = value
422        elif pspec.name == "filename":
423            self.set_filename(value)
424        elif pspec.name == "history-id":
425            self.__gentry.props.history_id = value
426        elif pspec.name == "modal":
427            self.__modal = value
428        else:
429            raise AttributeError("Unknown property: %s" % pspec.name)
430
431    def _get_last_open(self):
432        try:
433            return last_open[self.props.history_id]
434        except KeyError:
435            return None
436
437    def _set_last_open(self, path):
438        last_open[self.props.history_id] = path
439
440    def append_history(self, text):
441        self.__gentry.append_history(text)
442
443    def prepend_history(self, text):
444        self.__gentry.prepend_history(text)
445
446    def focus_entry(self):
447        self.__gentry.focus_entry()
448
449    def set_default_path(self, path):
450        if path:
451            self.__default_path = os.path.abspath(path)
452        else:
453            self.__default_path = None
454
455    def set_directory_entry(self, is_directory_entry):
456        self.directory_entry = is_directory_entry
457
458    def get_directory_entry(self):
459        return self.directory_entry
460
461    def _get_default(self):
462        default = self.__default_path
463        last_path = self._get_last_open()
464        if last_path and os.path.exists(last_path):
465            default = last_path
466        return default
467
468    def get_full_path(self):
469        text = self.__gentry.get_entry().get_text()
470        if not text:
471            return None
472        sys_text = gobject.filename_from_utf8(text)
473        filename = _expand_filename(sys_text, self._get_default())
474        if not filename:
475            return None
476        return filename
477
478    def set_filename(self, filename):
479        self.__gentry.get_entry().set_text(filename)
480
481    def __browse_dialog_ok(self, filewidget):
482        filename = filewidget.get_filename()
483        if not filename:
484            return
485
486        encoding = sys.getfilesystemencoding()
487        if encoding:
488            filename = text_type(filename, encoding)
489        entry = self.__gentry.get_entry()
490        entry.set_text(filename)
491        self._set_last_open(filename)
492        entry.activate()
493
494    def __browse_dialog_response(self, widget, response):
495        if response == gtk.RESPONSE_ACCEPT:
496            self.__browse_dialog_ok(widget)
497        widget.destroy()
498        self.fsw = None
499
500    def __build_filename(self):
501        default = self._get_default()
502
503        text = self.__gentry.get_entry().get_text()
504        if not text:
505            return default + os.sep
506
507        locale_text = gobject.filename_from_utf8(text)
508        if not locale_text:
509            return default + os.sep
510
511        filename = _expand_filename(locale_text, default)
512        if not filename:
513            return default + os.sep
514
515        if not filename.endswith(os.sep) and (self.__directory_entry or os.path.isdir(filename)):
516            filename += os.sep
517        return filename
518
519    def __browse_clicked(self, *args):
520        if self.fsw:
521            self.fsw.show()
522            if self.fsw.window:
523                self.fsw.window.raise_()
524            return
525
526        if self.__directory_entry:
527            action = gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER
528            filefilter = gtk.FileFilter()
529            filefilter.add_mime_type("x-directory/normal")
530            title = self.__browse_dialog_title or _("Select directory")
531        else:
532            action = self.__filechooser_action
533            filefilter = None
534            title = self.__browse_dialog_title or _("Select file")
535
536        if action == gtk.FILE_CHOOSER_ACTION_SAVE:
537            buttons = (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_SAVE, gtk.RESPONSE_ACCEPT)
538        else:
539            buttons = (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_OPEN, gtk.RESPONSE_ACCEPT)
540
541        self.fsw = gtk.FileChooserDialog(title, None, action, buttons, None)
542        self.fsw.props.filter = filefilter
543        self.fsw.set_default_response(gtk.RESPONSE_ACCEPT)
544        self.fsw.set_filename(self.__build_filename())
545        self.fsw.connect("response", self.__browse_dialog_response)
546
547        toplevel = self.get_toplevel()
548        modal_fentry = False
549        if toplevel.flags() & gtk.TOPLEVEL:
550            self.fsw.set_transient_for(toplevel)
551            modal_fentry = toplevel.get_modal()
552        if self.__modal or modal_fentry:
553            self.fsw.set_modal(True)
554
555        self.fsw.show()
556
557    def history_entry_drag_data_received(self, widget, context, x, y, selection_data, info, time):
558        uris = selection_data.data.split()
559        if not uris:
560            context.finish(False, False, time)
561            return
562
563        for uri in uris:
564            path = gio.File(uri=uri).get_path()
565            if path:
566                break
567        else:
568            context.finish(False, False, time)
569            return
570
571        entry = self.__gentry.get_entry()
572        entry.set_text(path)
573        context.finish(True, False, time)
574        self._set_last_open(path)
575        entry.activate()
576