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