class EncodingSelector(FilteredListSelector, Gtk.Grid): # The subclassing here is weird; the Selector must directly # subclass Gtk.Grid, or the template building explodes. __gtype_name__ = 'EncodingSelector' __gsignals__ = { 'encoding-selected': (GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION, None, (GtkSource.Encoding, )), } # These exist solely to make subclassing easier. value_accessor = 'get_charset' change_signal_name = 'encoding-selected' entry = Template.Child('entry') treeview = Template.Child('treeview') def populate_model(self): for enc in GtkSource.Encoding.get_all(): self.liststore.append((self.get_value_label(enc), enc)) def get_value_label(self, enc): return _('{name} ({charset})').format(name=enc.get_name(), charset=enc.get_charset())
class SourceLangSelector(FilteredListSelector, Gtk.Grid): __gtype_name__ = "SourceLangSelector" __gsignals__ = { 'language-selected': (GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION, None, (GtkSource.Language, )), } # These exist solely to make subclassing easier. value_accessor = 'get_id' change_signal_name = 'language-selected' entry = Template.Child('entry') treeview = Template.Child('treeview') def populate_model(self): self.liststore.append((_("Plain Text"), None)) manager = GtkSource.LanguageManager.get_default() for lang_id in manager.get_language_ids(): lang = manager.get_language(lang_id) self.liststore.append((lang.get_name(), lang)) def get_value_label(self, lang): if not lang: return _("Plain Text") return lang.get_name()
class NotebookLabel(Gtk.EventBox): __gtype_name__ = 'NotebookLabel' icon = Template.Child() label = Template.Child() icon_name = GObject.Property( type=str, nick='Name of the icon to display', default=None, ) label_text = GObject.Property( type=str, nick='Text of this notebook label', default='', ) page = GObject.Property( type=object, nick='Notebook page for which this is the label', default=None, ) def __init__(self, **kwargs): super().__init__(**kwargs) self.init_template() self.bind_property( 'icon-name', self.icon, 'icon-name', GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE, ) self.bind_property( 'label-text', self.label, 'label', GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE, ) self.bind_property( 'label-text', self, 'tooltip-text', GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE, ) @Template.Callback() def on_label_button_press_event(self, widget, event): # Middle-click on the tab closes the tab. if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 2: self.page.on_delete_event() @Template.Callback() def on_close_button_clicked(self, widget): self.page.on_delete_event()
class FindBar(Gtk.Grid): __gtype_name__ = 'FindBar' arrow_left = Template.Child() arrow_right = Template.Child() find_entry = Template.Child() find_next_button = Template.Child() find_previous_button = Template.Child() hbuttonbox2 = Template.Child() match_case = Template.Child() regex = Template.Child() replace_entry = Template.Child() replace_label = Template.Child() whole_word = Template.Child() wrap_box = Template.Child() def __init__(self, parent): super().__init__() self.init_template() self.set_text_view(None) self.arrow_left.show() self.arrow_right.show() parent.connect('set-focus-child', self.on_focus_child) settings = GtkSource.SearchSettings() self.match_case.bind_property('active', settings, 'case-sensitive') self.whole_word.bind_property('active', settings, 'at-word-boundaries') self.regex.bind_property('active', settings, 'regex-enabled') self.find_entry.bind_property('text', settings, 'search-text') settings.set_wrap_around(True) self.search_settings = settings def on_focus_child(self, container, widget): if widget is not None: visible = self.props.visible if widget is not self and visible: self.hide() return False def hide(self): self.set_text_view(None) self.wrap_box.set_visible(False) Gtk.Widget.hide(self) def set_text_view(self, textview): self.textview = textview if textview is not None: self.search_context = GtkSource.SearchContext.new( textview.get_buffer(), self.search_settings) self.search_context.set_highlight(True) else: self.search_context = None def start_find(self, textview, text=None): self.set_text_view(textview) self.replace_label.hide() self.replace_entry.hide() self.hbuttonbox2.hide() self.find_entry.get_style_context().remove_class("not-found") if text: self.find_entry.set_text(text) self.set_row_spacing(0) self.show() self.find_entry.grab_focus() def start_find_next(self, textview): self.set_text_view(textview) if self.find_entry.get_text(): self.on_find_next_button_clicked(self.find_next_button) else: self.start_find(self.textview) def start_find_previous(self, textview, text=None): self.set_text_view(textview) if self.find_entry.get_text(): self.on_find_previous_button_clicked(self.find_previous_button) else: self.start_find(self.textview) def start_replace(self, textview, text=None): self.set_text_view(textview) self.find_entry.get_style_context().remove_class("not-found") if text: self.find_entry.set_text(text) self.set_row_spacing(6) self.show_all() self.find_entry.grab_focus() self.wrap_box.set_visible(False) @Template.Callback() def on_find_next_button_clicked(self, button): self._find_text() @Template.Callback() def on_find_previous_button_clicked(self, button): self._find_text(backwards=True) @Template.Callback() def on_replace_button_clicked(self, entry): buf = self.textview.get_buffer() oldsel = buf.get_selection_bounds() match = self._find_text(0) newsel = buf.get_selection_bounds() # Only replace if there is an already-selected match at the cursor if (match and oldsel and oldsel[0].equal(newsel[0]) and oldsel[1].equal(newsel[1])): self.search_context.replace(newsel[0], newsel[1], self.replace_entry.get_text(), -1) self._find_text(0) @Template.Callback() def on_replace_all_button_clicked(self, entry): buf = self.textview.get_buffer() saved_insert = buf.create_mark(None, buf.get_iter_at_mark(buf.get_insert()), True) self.search_context.replace_all(self.replace_entry.get_text(), -1) if not saved_insert.get_deleted(): buf.place_cursor(buf.get_iter_at_mark(saved_insert)) self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5, 0.5) @Template.Callback() def on_find_entry_changed(self, entry): self.find_entry.get_style_context().remove_class("not-found") self._find_text(0) @Template.Callback() def on_stop_search(self, search_entry): self.hide() def _find_text(self, start_offset=1, backwards=False): assert self.textview assert self.search_context buf = self.textview.get_buffer() insert = buf.get_iter_at_mark(buf.get_insert()) start, end = buf.get_bounds() self.wrap_box.set_visible(False) if not backwards: insert.forward_chars(start_offset) match, start_iter, end_iter = self.search_context.forward(insert) if match and (start_iter.get_offset() < insert.get_offset()): self.wrap_box.set_visible(True) else: match, start_iter, end_iter = self.search_context.backward(insert) if match and (start_iter.get_offset() > insert.get_offset()): self.wrap_box.set_visible(True) if match: buf.place_cursor(start_iter) buf.move_mark(buf.get_selection_bound(), end_iter) self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5, 0.5) self.find_entry.get_style_context().remove_class("not-found") return True else: buf.place_cursor(buf.get_iter_at_mark(buf.get_insert())) self.find_entry.get_style_context().add_class("not-found") self.wrap_box.set_visible(False)
class FilterList(Gtk.VBox, EditableListWidget): __gtype_name__ = "FilterList" treeview = Template.Child("treeview") remove = Template.Child("remove") move_up = Template.Child("move_up") move_down = Template.Child("move_down") pattern_column = Template.Child("pattern_column") validity_renderer = Template.Child("validity_renderer") default_entry = [_("label"), False, _("pattern"), True] filter_type = GObject.Property( type=int, flags=(GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE | GObject.ParamFlags.CONSTRUCT_ONLY), ) settings_key = GObject.Property( type=str, flags=(GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE | GObject.ParamFlags.CONSTRUCT_ONLY), ) def __init__(self, **kwargs): super().__init__(self, **kwargs) FilterList.init_template(self) self.model = self.treeview.get_model() self.pattern_column.set_cell_data_func(self.validity_renderer, self.valid_icon_celldata) for filter_params in settings.get_value(self.settings_key): filt = FilterEntry.new_from_gsetting(filter_params, self.filter_type) if filt is None: continue valid = filt.filter is not None self.model.append( [filt.label, filt.active, filt.filter_string, valid]) for signal in ('row-changed', 'row-deleted', 'row-inserted', 'rows-reordered'): self.model.connect(signal, self._update_filter_string) self.setup_sensitivity_handling() def valid_icon_celldata(self, col, cell, model, it, user_data=None): is_valid = model.get_value(it, 3) icon_name = "gtk-dialog-warning" if not is_valid else None cell.set_property("stock-id", icon_name) @Template.Callback() def on_add_clicked(self, button): self.add_entry() @Template.Callback() def on_remove_clicked(self, button): self.remove_selected_entry() @Template.Callback() def on_move_up_clicked(self, button): self.move_up_selected_entry() @Template.Callback() def on_move_down_clicked(self, button): self.move_down_selected_entry() @Template.Callback() def on_name_edited(self, ren, path, text): self.model[path][0] = text @Template.Callback() def on_cellrenderertoggle_toggled(self, ren, path): self.model[path][1] = not ren.get_active() @Template.Callback() def on_pattern_edited(self, ren, path, text): valid = FilterEntry.check_filter(text, self.filter_type) self.model[path][2] = text self.model[path][3] = valid def _update_filter_string(self, *args): value = [(row[0], row[1], row[2]) for row in self.model] settings.set_value(self.settings_key, GLib.Variant('a(sbs)', value))
class PreferencesDialog(Gtk.Dialog): __gtype_name__ = "PreferencesDialog" checkbutton_break_commit_lines = Template.Child( "checkbutton_break_commit_lines") # noqa: E501 checkbutton_default_font = Template.Child("checkbutton_default_font") checkbutton_folder_filter_text = Template.Child( "checkbutton_folder_filter_text") # noqa: E501 checkbutton_highlight_current_line = Template.Child( "checkbutton_highlight_current_line") # noqa: E501 checkbutton_ignore_blank_lines = Template.Child( "checkbutton_ignore_blank_lines") # noqa: E501 checkbutton_ignore_symlinks = Template.Child("checkbutton_ignore_symlinks") checkbutton_shallow_compare = Template.Child("checkbutton_shallow_compare") checkbutton_show_commit_margin = Template.Child( "checkbutton_show_commit_margin") # noqa: E501 checkbutton_show_line_numbers = Template.Child( "checkbutton_show_line_numbers") # noqa: E501 checkbutton_show_whitespace = Template.Child("checkbutton_show_whitespace") checkbutton_spaces_instead_of_tabs = Template.Child( "checkbutton_spaces_instead_of_tabs") # noqa: E501 checkbutton_use_syntax_highlighting = Template.Child( "checkbutton_use_syntax_highlighting") # noqa: E501 checkbutton_wrap_text = Template.Child("checkbutton_wrap_text") checkbutton_wrap_word = Template.Child("checkbutton_wrap_word") column_list_vbox = Template.Child("column_list_vbox") combo_file_order = Template.Child("combo_file_order") combo_merge_order = Template.Child("combo_merge_order") combo_timestamp = Template.Child("combo_timestamp") combobox_style_scheme = Template.Child("combobox_style_scheme") custom_edit_command_entry = Template.Child("custom_edit_command_entry") file_filters_vbox = Template.Child("file_filters_vbox") fontpicker = Template.Child("fontpicker") spinbutton_commit_margin = Template.Child("spinbutton_commit_margin") spinbutton_tabsize = Template.Child("spinbutton_tabsize") syntaxschemestore = Template.Child("syntaxschemestore") system_editor_checkbutton = Template.Child("system_editor_checkbutton") text_filters_vbox = Template.Child("text_filters_vbox") def __init__(self, **kwargs): super().__init__(**kwargs) self.init_template() bindings = [ ('use-system-font', self.checkbutton_default_font, 'active'), ('custom-font', self.fontpicker, 'font'), ('indent-width', self.spinbutton_tabsize, 'value'), ('insert-spaces-instead-of-tabs', self.checkbutton_spaces_instead_of_tabs, 'active'), # noqa: E501 ('highlight-current-line', self.checkbutton_highlight_current_line, 'active'), # noqa: E501 ('show-line-numbers', self.checkbutton_show_line_numbers, 'active'), # noqa: E501 ('highlight-syntax', self.checkbutton_use_syntax_highlighting, 'active'), # noqa: E501 ('use-system-editor', self.system_editor_checkbutton, 'active'), ('custom-editor-command', self.custom_edit_command_entry, 'text'), ('folder-shallow-comparison', self.checkbutton_shallow_compare, 'active'), # noqa: E501 ('folder-filter-text', self.checkbutton_folder_filter_text, 'active'), # noqa: E501 ('folder-ignore-symlinks', self.checkbutton_ignore_symlinks, 'active'), # noqa: E501 ('vc-show-commit-margin', self.checkbutton_show_commit_margin, 'active'), # noqa: E501 ('vc-commit-margin', self.spinbutton_commit_margin, 'value'), ('vc-break-commit-message', self.checkbutton_break_commit_lines, 'active'), # noqa: E501 ('ignore-blank-lines', self.checkbutton_ignore_blank_lines, 'active'), # noqa: E501 # Sensitivity bindings must come after value bindings, or the key # writability in gsettings overrides manual sensitivity setting. ('vc-show-commit-margin', self.spinbutton_commit_margin, 'sensitive'), # noqa: E501 ('vc-show-commit-margin', self.checkbutton_break_commit_lines, 'sensitive'), # noqa: E501 ] for key, obj, attribute in bindings: settings.bind(key, obj, attribute, Gio.SettingsBindFlags.DEFAULT) invert_bindings = [ ('use-system-editor', self.custom_edit_command_entry, 'sensitive'), ('use-system-font', self.fontpicker, 'sensitive'), ('folder-shallow-comparison', self.checkbutton_folder_filter_text, 'sensitive'), # noqa: E501 ] for key, obj, attribute in invert_bindings: settings.bind( key, obj, attribute, Gio.SettingsBindFlags.DEFAULT | Gio.SettingsBindFlags.INVERT_BOOLEAN) self.checkbutton_wrap_text.bind_property('active', self.checkbutton_wrap_word, 'sensitive', GObject.BindingFlags.DEFAULT) # TODO: Fix once bind_with_mapping is available self.checkbutton_show_whitespace.set_active( bool(settings.get_flags('draw-spaces'))) wrap_mode = settings.get_enum('wrap-mode') self.checkbutton_wrap_text.set_active(wrap_mode != Gtk.WrapMode.NONE) self.checkbutton_wrap_word.set_active(wrap_mode == Gtk.WrapMode.WORD) filefilter = FilterList( filter_type=FilterEntry.SHELL, settings_key="filename-filters", ) self.file_filters_vbox.pack_start(filefilter, True, True, 0) textfilter = FilterList( filter_type=FilterEntry.REGEX, settings_key="text-filters", ) self.text_filters_vbox.pack_start(textfilter, True, True, 0) columnlist = ColumnList(settings_key="folder-columns") self.column_list_vbox.pack_start(columnlist, True, True, 0) self.combo_timestamp.bind_to('folder-time-resolution') self.combo_file_order.bind_to('vc-left-is-local') self.combo_merge_order.bind_to('vc-merge-file-order') # Fill color schemes manager = GtkSource.StyleSchemeManager.get_default() for scheme_id in manager.get_scheme_ids(): scheme = manager.get_scheme(scheme_id) self.syntaxschemestore.append([scheme_id, scheme.get_name()]) self.combobox_style_scheme.bind_to('style-scheme') self.show() @Template.Callback() def on_checkbutton_wrap_text_toggled(self, button): if not self.checkbutton_wrap_text.get_active(): wrap_mode = Gtk.WrapMode.NONE elif self.checkbutton_wrap_word.get_active(): wrap_mode = Gtk.WrapMode.WORD else: wrap_mode = Gtk.WrapMode.CHAR settings.set_enum('wrap-mode', wrap_mode) @Template.Callback() def on_checkbutton_show_whitespace_toggled(self, widget): value = GtkSource.DrawSpacesFlags.ALL if widget.get_active() else 0 settings.set_flags('draw-spaces', value) @Template.Callback() def on_response(self, dialog, response_id): self.destroy()
class ColumnList(Gtk.VBox, EditableListWidget): __gtype_name__ = "ColumnList" treeview = Template.Child("treeview") remove = Template.Child("remove") move_up = Template.Child("move_up") move_down = Template.Child("move_down") default_entry = [_("label"), False, _("pattern"), True] available_columns = { "size": _("Size"), "modification time": _("Modification time"), "permissions": _("Permissions"), } settings_key = GObject.Property( type=str, flags=(GObject.ParamFlags.READABLE | GObject.ParamFlags.WRITABLE | GObject.ParamFlags.CONSTRUCT_ONLY), ) def __init__(self, **kwargs): super().__init__(self, **kwargs) ColumnList.init_template(self) self.model = self.treeview.get_model() # Unwrap the variant prefs_columns = [(k, v) for k, v in settings.get_value(self.settings_key)] column_vis = {} column_order = {} for sort_key, (column_name, visibility) in enumerate(prefs_columns): column_vis[column_name] = bool(int(visibility)) column_order[column_name] = sort_key columns = [(column_vis.get(name, True), name, label) for name, label in self.available_columns.items()] columns = sorted(columns, key=lambda c: column_order.get(c[1], 0)) for visibility, name, label in columns: self.model.append([visibility, name, label]) for signal in ('row-changed', 'row-deleted', 'row-inserted', 'rows-reordered'): self.model.connect(signal, self._update_columns) self.setup_sensitivity_handling() @Template.Callback() def on_move_up_clicked(self, button): self.move_up_selected_entry() @Template.Callback() def on_move_down_clicked(self, button): self.move_down_selected_entry() @Template.Callback() def on_cellrenderertoggle_toggled(self, ren, path): self.model[path][0] = not ren.get_active() def _update_columns(self, *args): value = [(c[1].lower(), c[0]) for c in self.model] settings.set_value(self.settings_key, GLib.Variant('a(sb)', value))
class CommitDialog(Gtk.Dialog): __gtype_name__ = "CommitDialog" break_commit_message = GObject.Property(type=bool, default=False) changedfiles = Template.Child("changedfiles") textview = Template.Child("textview") scrolledwindow1 = Template.Child("scrolledwindow1") previousentry = Template.Child("previousentry") def __init__(self, parent): super().__init__() self.init_template() self.set_transient_for(parent.get_toplevel()) selected = parent._get_selected_files() try: to_commit = parent.vc.get_files_to_commit(selected) topdir = parent.vc.root if to_commit: to_commit = ["\t" + s for s in to_commit] else: to_commit = ["\t" + _("No files will be committed")] except NotImplementedError: topdir = os.path.dirname(os.path.commonprefix(selected)) to_commit = ["\t" + s[len(topdir) + 1:] for s in selected] self.changedfiles.set_text("(in %s)\n%s" % (topdir, "\n".join(to_commit))) self.textview.modify_font(meldsettings.font) commit_prefill = parent.vc.get_commit_message_prefill() if commit_prefill: buf = self.textview.get_buffer() buf.set_text(commit_prefill) buf.place_cursor(buf.get_start_iter()) # Try and make the textview wide enough for a standard 80-character # commit message. context = self.textview.get_pango_context() metrics = context.get_metrics(meldsettings.font, context.get_language()) char_width = metrics.get_approximate_char_width() / Pango.SCALE self.scrolledwindow1.set_size_request(80 * char_width, -1) settings.bind('vc-show-commit-margin', self.textview, 'show-right-margin', Gio.SettingsBindFlags.DEFAULT) settings.bind('vc-commit-margin', self.textview, 'right-margin-position', Gio.SettingsBindFlags.DEFAULT) settings.bind('vc-break-commit-message', self, 'break-commit-message', Gio.SettingsBindFlags.DEFAULT) self.show_all() def run(self): self.previousentry.set_active(-1) self.textview.grab_focus() response = super().run() msg = None if response == Gtk.ResponseType.OK: show_margin = self.textview.get_show_right_margin() margin = self.textview.get_right_margin_position() buf = self.textview.get_buffer() msg = buf.get_text(*buf.get_bounds(), include_hidden_chars=False) # This is a dependent option because of the margin column if show_margin and self.props.break_commit_message: paragraphs = msg.split("\n\n") msg = "\n\n".join(textwrap.fill(p, margin) for p in paragraphs) if msg.strip(): self.previousentry.prepend_history(msg) self.destroy() return response, msg def on_previousentry_activate(self, gentry): idx = gentry.get_active() if idx != -1: model = gentry.get_model() buf = self.textview.get_buffer() buf.set_text(model[idx][1])
class MeldWindow(Gtk.ApplicationWindow): __gtype_name__ = 'MeldWindow' appvbox = Template.Child("appvbox") folder_filter_button = Template.Child() text_filter_button = Template.Child() gear_menu_button = Template.Child("gear_menu_button") notebook = Template.Child("notebook") spinner = Template.Child("spinner") vc_filter_button = Template.Child() view_toolbar = Template.Child() def __init__(self): super().__init__() self.init_template() # Manually handle GAction additions actions = ( ("close", self.action_close), ("new-tab", self.action_new_tab), ("stop", self.action_stop), ) for name, callback in actions: action = Gio.SimpleAction.new(name, None) action.connect('activate', callback) self.add_action(action) state_actions = ( ( "fullscreen", self.action_fullscreen_change, GLib.Variant.new_boolean(False) ), ) for (name, callback, state) in state_actions: action = Gio.SimpleAction.new_stateful(name, None, state) action.connect('change-state', callback) self.add_action(action) # Initialise sensitivity for important actions self.lookup_action('stop').set_enabled(False) # Fake out the spinner on Windows. See Gitlab issue #133. if os.name == 'nt': for attr in ('stop', 'hide', 'show', 'start'): setattr(self.spinner, attr, lambda *args: True) self.drag_dest_set( Gtk.DestDefaults.MOTION | Gtk.DestDefaults.HIGHLIGHT | Gtk.DestDefaults.DROP, None, Gdk.DragAction.COPY) self.drag_dest_add_uri_targets() self.connect( "drag_data_received", self.on_widget_drag_data_received) self.window_state = SavedWindowState() self.window_state.bind(self) self.should_close = False self.idle_hooked = 0 self.scheduler = LifoScheduler() self.scheduler.connect("runnable", self.on_scheduler_runnable) def do_realize(self): Gtk.ApplicationWindow.do_realize(self) app = self.get_application() menu = app.get_menu_by_id("gear-menu") self.gear_menu_button.set_popover( Gtk.Popover.new_from_model(self.gear_menu_button, menu)) filter_model = app.get_menu_by_id("text-filter-menu") self.text_filter_button.set_popover( Gtk.Popover.new_from_model(self.text_filter_button, filter_model)) filter_menu = app.get_menu_by_id("folder-status-filter-menu") self.folder_filter_button.set_popover( Gtk.Popover.new_from_model(self.folder_filter_button, filter_menu)) vc_filter_model = app.get_menu_by_id('vc-status-filter-menu') self.vc_filter_button.set_popover( Gtk.Popover.new_from_model(self.vc_filter_button, vc_filter_model)) self.update_text_filters() self.update_filename_filters() self.settings_handlers = [ meldsettings.connect( "text-filters-changed", self.update_text_filters), meldsettings.connect( "file-filters-changed", self.update_filename_filters), ] meld.ui.util.extract_accels_from_menu(menu, self.get_application()) def update_filename_filters(self, *args): filter_items_model = Gio.Menu() for i, filt in enumerate(meldsettings.file_filters): name = FILE_FILTER_ACTION_FORMAT.format(i) filter_items_model.append( label=filt.label, detailed_action=f'view.{name}') section = Gio.MenuItem.new_section(_("Filename"), filter_items_model) section.set_attribute([("id", "s", "custom-filter-section")]) app = self.get_application() filter_model = app.get_menu_by_id("folder-status-filter-menu") replace_menu_section(filter_model, section) def update_text_filters(self, *args): filter_items_model = Gio.Menu() for i, filt in enumerate(meldsettings.text_filters): name = TEXT_FILTER_ACTION_FORMAT.format(i) filter_items_model.append( label=filt.label, detailed_action=f'view.{name}') section = Gio.MenuItem.new_section(None, filter_items_model) section.set_attribute([("id", "s", "custom-filter-section")]) app = self.get_application() filter_model = app.get_menu_by_id("text-filter-menu") replace_menu_section(filter_model, section) def on_widget_drag_data_received( self, wid, context, x, y, selection_data, info, time): uris = selection_data.get_uris() if uris: self.open_paths([Gio.File.new_for_uri(uri) for uri in uris]) return True def on_idle(self): ret = self.scheduler.iteration() if ret and isinstance(ret, str): self.spinner.set_tooltip_text(ret) pending = self.scheduler.tasks_pending() if not pending: self.spinner.stop() self.spinner.hide() self.spinner.set_tooltip_text("") self.idle_hooked = None # On window close, this idle loop races widget destruction, # and so actions may already be gone at this point. stop_action = self.lookup_action('stop') if stop_action: stop_action.set_enabled(False) return pending def on_scheduler_runnable(self, sched): if not self.idle_hooked: self.spinner.show() self.spinner.start() self.lookup_action('stop').set_enabled(True) self.idle_hooked = GLib.idle_add(self.on_idle) @Template.Callback() def on_delete_event(self, *extra): should_cancel = False # Delete pages from right-to-left. This ensures that if a version # control page is open in the far left page, it will be closed last. for page in reversed(self.notebook.get_children()): self.notebook.set_current_page(self.notebook.page_num(page)) response = page.on_delete_event() if response == Gtk.ResponseType.CANCEL: should_cancel = True should_cancel = should_cancel or self.has_pages() if should_cancel: self.should_close = True return should_cancel def has_pages(self): return self.notebook.get_n_pages() > 0 def handle_current_doc_switch(self, page): page.on_container_switch_out_event(self) @Template.Callback() def on_switch_page(self, notebook, page, which): oldidx = notebook.get_current_page() if oldidx >= 0: olddoc = notebook.get_nth_page(oldidx) self.handle_current_doc_switch(olddoc) newdoc = notebook.get_nth_page(which) if which >= 0 else None self.lookup_action('close').set_enabled(bool(newdoc)) if newdoc: nbl = self.notebook.get_tab_label(newdoc) self.set_title(nbl.props.label_text) else: self.set_title("Meld") if hasattr(newdoc, 'scheduler'): self.scheduler.add_task(newdoc.scheduler) self.view_toolbar.foreach(self.view_toolbar.remove) if hasattr(newdoc, 'toolbar_actions'): self.view_toolbar.add(newdoc.toolbar_actions) @Template.Callback() def after_switch_page(self, notebook, page, which): newdoc = notebook.get_nth_page(which) newdoc.on_container_switch_in_event(self) @Template.Callback() def on_page_label_changed(self, notebook, label_text): self.set_title(label_text) def action_new_tab(self, action, parameter): self.append_new_comparison() def action_close(self, *extra): i = self.notebook.get_current_page() if i >= 0: page = self.notebook.get_nth_page(i) page.on_delete_event() def action_fullscreen_change(self, action, state): window_state = self.get_window().get_state() is_full = window_state & Gdk.WindowState.FULLSCREEN action.set_state(state) if state and not is_full: self.fullscreen() elif is_full: self.unfullscreen() def action_stop(self, *args): # TODO: This is the only window-level action we have that still # works on the "current" document like this. self.current_doc().action_stop() def page_removed(self, page, status): if hasattr(page, 'scheduler'): self.scheduler.remove_scheduler(page.scheduler) page_num = self.notebook.page_num(page) if self.notebook.get_current_page() == page_num: self.handle_current_doc_switch(page) self.notebook.remove_page(page_num) # Normal switch-page handlers don't get run for removing the # last page from a notebook. if not self.has_pages(): self.on_switch_page(self.notebook, page, -1) if self.should_close: cancelled = self.emit( 'delete-event', Gdk.Event.new(Gdk.EventType.DELETE)) if not cancelled: self.emit('destroy') def on_page_state_changed(self, page, old_state, new_state): if self.should_close and old_state == ComparisonState.Closing: # Cancel closing if one of our tabs does self.should_close = False def on_file_changed(self, srcpage, filename): for page in self.notebook.get_children(): if page != srcpage: page.on_file_changed(filename) @Template.Callback() def on_open_recent(self, recent_selector, uri): try: self.append_recent(uri) except (IOError, ValueError): # FIXME: Need error handling, but no sensible display location log.exception(f'Error opening recent file {uri}') def _append_page(self, page, icon): nbl = NotebookLabel(icon_name=icon, page=page) self.notebook.append_page(page, nbl) self.notebook.child_set_property(page, 'tab-expand', True) # Change focus to the newly created page only if the user is on a # DirDiff or VcView page, or if it's a new tab page. This prevents # cycling through X pages when X diffs are initiated. if isinstance(self.current_doc(), DirDiff) or \ isinstance(self.current_doc(), VcView) or \ isinstance(page, NewDiffTab): self.notebook.set_current_page(self.notebook.page_num(page)) if hasattr(page, 'scheduler'): self.scheduler.add_scheduler(page.scheduler) if isinstance(page, MeldDoc): page.file_changed_signal.connect(self.on_file_changed) page.create_diff_signal.connect( lambda obj, arg, kwargs: self.append_diff(arg, **kwargs)) page.tab_state_changed.connect(self.on_page_state_changed) page.close_signal.connect(self.page_removed) self.notebook.set_tab_reorderable(page, True) def append_new_comparison(self): doc = NewDiffTab(self) self._append_page(doc, "document-new") self.notebook.on_label_changed(doc, _("New comparison"), None) def diff_created_cb(doc, newdoc): doc.on_delete_event() idx = self.notebook.page_num(newdoc) self.notebook.set_current_page(idx) doc.connect("diff-created", diff_created_cb) return doc def append_dirdiff(self, gfiles, auto_compare=False): dirs = [d.get_path() if d else None for d in gfiles] assert len(dirs) in (1, 2, 3) doc = DirDiff(len(dirs)) self._append_page(doc, "folder") doc.set_locations(dirs) if auto_compare: doc.scheduler.add_task(doc.auto_compare) return doc def append_filediff( self, gfiles, *, encodings=None, merge_output=None, meta=None): assert len(gfiles) in (1, 2, 3) doc = FileDiff(len(gfiles)) self._append_page(doc, "text-x-generic") doc.set_files(gfiles, encodings) if merge_output is not None: doc.set_merge_output_file(merge_output) if meta is not None: doc.set_meta(meta) return doc def append_filemerge(self, gfiles, merge_output=None): if len(gfiles) != 3: raise ValueError( _("Need three files to auto-merge, got: %r") % [f.get_parse_name() for f in gfiles]) doc = FileMerge(len(gfiles)) self._append_page(doc, "text-x-generic") doc.set_files(gfiles) if merge_output is not None: doc.set_merge_output_file(merge_output) return doc def append_diff(self, gfiles, auto_compare=False, auto_merge=False, merge_output=None, meta=None): have_directories = False have_files = False for f in gfiles: if f.query_file_type( Gio.FileQueryInfoFlags.NONE, None) == Gio.FileType.DIRECTORY: have_directories = True else: have_files = True if have_directories and have_files: raise ValueError( _("Cannot compare a mixture of files and directories")) elif have_directories: return self.append_dirdiff(gfiles, auto_compare) elif auto_merge: return self.append_filemerge(gfiles, merge_output=merge_output) else: return self.append_filediff( gfiles, merge_output=merge_output, meta=meta) def append_vcview(self, location, auto_compare=False): doc = VcView() self._append_page(doc, "meld-version-control") if isinstance(location, (list, tuple)): location = location[0] doc.set_location(location.get_path()) if auto_compare: doc.scheduler.add_task(doc.auto_compare) return doc def append_recent(self, uri): comparison_type, gfiles = recent_comparisons.read(uri) comparison_method = { RecentType.File: self.append_filediff, RecentType.Folder: self.append_dirdiff, RecentType.Merge: self.append_filemerge, RecentType.VersionControl: self.append_vcview, } tab = comparison_method[comparison_type](gfiles) self.notebook.set_current_page(self.notebook.page_num(tab)) recent_comparisons.add(tab) return tab def _single_file_open(self, gfile): doc = VcView() def cleanup(): self.scheduler.remove_scheduler(doc.scheduler) self.scheduler.add_task(cleanup) self.scheduler.add_scheduler(doc.scheduler) path = gfile.get_path() doc.set_location(path) doc.create_diff_signal.connect( lambda obj, arg, kwargs: self.append_diff(arg, **kwargs)) doc.run_diff(path) def open_paths(self, gfiles, auto_compare=False, auto_merge=False, focus=False): tab = None if len(gfiles) == 1: a = gfiles[0] if a.query_file_type(Gio.FileQueryInfoFlags.NONE, None) == \ Gio.FileType.DIRECTORY: tab = self.append_vcview(a, auto_compare) else: self._single_file_open(a) elif len(gfiles) in (2, 3): tab = self.append_diff(gfiles, auto_compare=auto_compare, auto_merge=auto_merge) if tab: recent_comparisons.add(tab) if focus: self.notebook.set_current_page(self.notebook.page_num(tab)) return tab def current_doc(self): "Get the current doc or a dummy object if there is no current" index = self.notebook.get_current_page() if index >= 0: page = self.notebook.get_nth_page(index) if isinstance(page, MeldDoc): return page class DummyDoc: def __getattr__(self, a): return lambda *x: None return DummyDoc()
class VcView(Gtk.VBox, tree.TreeviewCommon, MeldDoc): __gtype_name__ = "VcView" __gsettings_bindings__ = ( ('vc-status-filters', 'status-filters'), ('vc-left-is-local', 'left-is-local'), ('vc-merge-file-order', 'merge-file-order'), ) close_signal = MeldDoc.close_signal create_diff_signal = MeldDoc.create_diff_signal file_changed_signal = MeldDoc.file_changed_signal label_changed = MeldDoc.label_changed tab_state_changed = MeldDoc.tab_state_changed status_filters = GObject.Property( type=GObject.TYPE_STRV, nick="File status filters", blurb="Files with these statuses will be shown by the comparison.", ) left_is_local = GObject.Property(type=bool, default=False) merge_file_order = GObject.Property(type=str, default="local-merge-remote") # Map for inter-tab command() calls command_map = { 'resolve': 'resolve', } state_actions = { 'flatten': ('vc-flatten', None), 'modified': ('vc-status-modified', Entry.is_modified), 'normal': ('vc-status-normal', Entry.is_normal), 'unknown': ('vc-status-unknown', Entry.is_nonvc), 'ignored': ('vc-status-ignored', Entry.is_ignored), } combobox_vcs = Template.Child() console_vbox = Template.Child() consoleview = Template.Child() emblem_renderer = Template.Child() extra_column = Template.Child() extra_renderer = Template.Child() fileentry = Template.Child() liststore_vcs = Template.Child() location_column = Template.Child() location_renderer = Template.Child() name_column = Template.Child() name_renderer = Template.Child() status_column = Template.Child() status_renderer = Template.Child() treeview = Template.Child() vc_console_vpaned = Template.Child() def __init__(self): super().__init__() # FIXME: # This unimaginable hack exists because GObject (or GTK+?) # doesn't actually correctly chain init calls, even if they're # not to GObjects. As a workaround, we *should* just be able to # put our class first, but because of Gtk.Template we can't do # that if it's a GObject, because GObject doesn't support # multiple inheritance and we need to inherit from our Widget # parent to make Template work. MeldDoc.__init__(self) self.init_template() bind_settings(self) # Set up per-view action group for top-level menu insertion self.view_action_group = Gio.SimpleActionGroup() property_actions = (('vc-console-visible', self.console_vbox, 'visible'), ) for action_name, obj, prop_name in property_actions: action = Gio.PropertyAction.new(action_name, obj, prop_name) self.view_action_group.add_action(action) # Manually handle GAction additions actions = ( ('compare', self.action_diff), ('find', self.action_find), ('next-change', self.action_next_change), ('open-external', self.action_open_external), ('previous-change', self.action_previous_change), ('refresh', self.action_refresh), ('vc-add', self.action_add), ('vc-commit', self.action_commit), ('vc-delete-locally', self.action_delete), ('vc-push', self.action_push), ('vc-remove', self.action_remove), ('vc-resolve', self.action_resolved), ('vc-revert', self.action_revert), ('vc-update', self.action_update), ) for name, callback in actions: action = Gio.SimpleAction.new(name, None) action.connect('activate', callback) self.view_action_group.add_action(action) new_boolean = GLib.Variant.new_boolean stateful_actions = ( ('vc-flatten', self.action_filter_state_change, new_boolean('flatten' in self.props.status_filters)), ('vc-status-modified', self.action_filter_state_change, new_boolean('modified' in self.props.status_filters)), ('vc-status-normal', self.action_filter_state_change, new_boolean('normal' in self.props.status_filters)), ('vc-status-unknown', self.action_filter_state_change, new_boolean('unknown' in self.props.status_filters)), ('vc-status-ignored', self.action_filter_state_change, new_boolean('ignored' in self.props.status_filters)), ) for (name, callback, state) in stateful_actions: action = Gio.SimpleAction.new_stateful(name, None, state) action.connect('change-state', callback) self.view_action_group.add_action(action) builder = Gtk.Builder.new_from_resource( '/org/gnome/meld/ui/vcview-menus.ui') context_menu = builder.get_object('vcview-context-menu') self.popup_menu = Gtk.Menu.new_from_model(context_menu) self.popup_menu.attach_to_widget(self) self.model = VcTreeStore() self.connect("style-updated", self.model.on_style_updated) self.model.on_style_updated(self) self.treeview.set_model(self.model) self.treeview.get_selection().connect( "changed", self.on_treeview_selection_changed) self.treeview.set_search_equal_func(tree.treeview_search_cb, None) self.current_path, self.prev_path, self.next_path = None, None, None self.name_column.set_attributes(self.emblem_renderer, icon_name=tree.COL_ICON, icon_tint=tree.COL_TINT) self.name_column.set_attributes(self.name_renderer, text=tree.COL_TEXT, foreground_rgba=tree.COL_FG, style=tree.COL_STYLE, weight=tree.COL_WEIGHT, strikethrough=tree.COL_STRIKE) self.location_column.set_attributes(self.location_renderer, markup=COL_LOCATION) self.status_column.set_attributes(self.status_renderer, markup=COL_STATUS) self.extra_column.set_attributes(self.extra_renderer, markup=COL_OPTIONS) self.consolestream = ConsoleStream(self.consoleview) self.location = None self.vc = None settings.bind('vc-console-visible', self.console_vbox, 'visible', Gio.SettingsBindFlags.DEFAULT) settings.bind('vc-console-pane-position', self.vc_console_vpaned, 'position', Gio.SettingsBindFlags.DEFAULT) def on_container_switch_in_event(self, window): super().on_container_switch_in_event(window) # FIXME: open-external should be tied to having a treeview selection self.set_action_enabled("open-external", True) self.scheduler.add_task(self.on_treeview_cursor_changed) def on_container_switch_out_event(self, window): self.set_action_enabled("open-external", False) super().on_container_switch_out_event(window) def get_default_vc(self, vcs): target_name = self.vc.NAME if self.vc else None for i, (name, vc, enabled) in enumerate(vcs): if not enabled: continue if target_name and name == target_name: return i depths = [len(getattr(vc, 'root', [])) for name, vc, enabled in vcs] target_depth = max(depths, default=0) for i, (name, vc, enabled) in enumerate(vcs): if not enabled: continue if target_depth and len(vc.root) == target_depth: return i return 0 def populate_vcs_for_location(self, location): """Display VC plugin(s) that can handle the location""" vcs_model = self.combobox_vcs.get_model() vcs_model.clear() # VC systems can be executed at the directory level, so make sure # we're checking for VC support there instead of # on a specific file or on deleted/unexisting path inside vc location = os.path.abspath(location or ".") while not os.path.isdir(location): parent_location = os.path.dirname(location) if len(parent_location) >= len(location): # no existing parent: for example unexisting drive on Windows break location = parent_location else: # existing parent directory was found for avc, enabled in get_vcs(location): err_str = '' vc_details = {'name': avc.NAME, 'cmd': avc.CMD} if not enabled: # Translators: This error message is shown when no # repository of this type is found. err_str = _("%(name)s (not found)") elif not avc.is_installed(): # Translators: This error message is shown when a version # control binary isn't installed. err_str = _("%(name)s (%(cmd)s not installed)") elif not avc.valid_repo(location): # Translators: This error message is shown when a version # controlled repository is invalid. err_str = _("%(name)s (invalid repository)") if err_str: vcs_model.append([err_str % vc_details, avc, False]) continue vcs_model.append([avc.NAME, avc(location), True]) default_active = self.get_default_vc(vcs_model) if not any(enabled for _, _, enabled in vcs_model): # If we didn't get any valid vcs then fallback to null null_vcs = _null.Vc(location) vcs_model.insert(0, [null_vcs.NAME, null_vcs, True]) tooltip = _("No valid version control system found in this folder") else: tooltip = _("Choose which version control system to use") self.combobox_vcs.set_tooltip_text(tooltip) self.combobox_vcs.set_active(default_active) @Template.Callback() def on_vc_change(self, combobox_vcs): active_iter = combobox_vcs.get_active_iter() if active_iter is None: return self.vc = combobox_vcs.get_model()[active_iter][1] self._set_location(self.vc.location) def set_location(self, location): self.populate_vcs_for_location(location) def _set_location(self, location): self.location = location self.current_path = None self.model.clear() self.fileentry.set_filename(location) it = self.model.add_entries(None, [location]) self.treeview.grab_focus() self.treeview.get_selection().select_iter(it) self.model.set_path_state(it, 0, tree.STATE_NORMAL, isdir=1) self.recompute_label() self.scheduler.remove_all_tasks() # If the user is just diffing a file (i.e., not a directory), # there's no need to scan the rest of the repository. if not os.path.isdir(self.vc.location): return root = self.model.get_iter_first() root_path = self.model.get_path(root) try: self.model.set_value(root, COL_OPTIONS, self.vc.get_commits_to_push_summary()) except NotImplementedError: pass self.scheduler.add_task(self.vc.refresh_vc_state) self.scheduler.add_task(self._search_recursively_iter(root_path)) self.scheduler.add_task(self.on_treeview_selection_changed) self.scheduler.add_task(self.on_treeview_cursor_changed) def get_comparison(self): uris = [Gio.File.new_for_path(self.location)] return RecentType.VersionControl, uris def recompute_label(self): self.label_text = os.path.basename(self.location) # TRANSLATORS: This is the location of the directory being viewed self.tooltip_text = _("%s: %s") % (_("Location"), self.location) self.label_changed.emit(self.label_text, self.tooltip_text) def _search_recursively_iter(self, start_path, replace=False): # Initial yield so when we add this to our tasks, we don't # create iterators that may be invalidated. yield _("Scanning repository") if replace: # Replace the row at start_path with a new, empty row ready # to be filled. old_iter = self.model.get_iter(start_path) file_path = self.model.get_file_path(old_iter) new_iter = self.model.insert_after(None, old_iter) self.model.set_value(new_iter, tree.COL_PATH, file_path) self.model.set_path_state(new_iter, 0, tree.STATE_NORMAL, True) self.model.remove(old_iter) iterstart = self.model.get_iter(start_path) rootname = self.model.get_file_path(iterstart) display_prefix = len(rootname) + 1 symlinks_followed = set() todo = [(self.model.get_path(iterstart), rootname)] flattened = 'flatten' in self.props.status_filters active_actions = [ self.state_actions.get(k) for k in self.props.status_filters ] filters = [a[1] for a in active_actions if a and a[1]] while todo: # This needs to happen sorted and depth-first in order for our row # references to remain valid while we traverse. todo.sort() treepath, path = todo.pop(0) it = self.model.get_iter(treepath) yield _("Scanning %s") % path[display_prefix:] entries = self.vc.get_entries(path) entries = [e for e in entries if any(f(e) for f in filters)] entries = sorted(entries, key=lambda e: e.name) entries = sorted(entries, key=lambda e: not e.isdir) for e in entries: if e.isdir and e.is_present(): try: st = os.lstat(e.path) # Covers certain unreadable symlink cases; see bgo#585895 except OSError as err: error_string = "%r: %s" % (e.path, err.strerror) self.model.add_error(it, error_string, 0) continue if stat.S_ISLNK(st.st_mode): key = (st.st_dev, st.st_ino) if key in symlinks_followed: continue symlinks_followed.add(key) if flattened: if e.state != tree.STATE_IGNORED: # If directory state is changed, render it in # in flattened mode. if e.state != tree.STATE_NORMAL: child = self.model.add_entries(it, [e.path]) self._update_item_state(child, e) todo.append((Gtk.TreePath.new_first(), e.path)) continue child = self.model.add_entries(it, [e.path]) if e.isdir and e.state != tree.STATE_IGNORED: todo.append((self.model.get_path(child), e.path)) self._update_item_state(child, e) if not flattened: if not entries: self.model.add_empty(it, _("(Empty)")) elif any(e.state != tree.STATE_NORMAL for e in entries): self.treeview.expand_to_path(treepath) self.treeview.expand_row(Gtk.TreePath.new_first(), False) self.treeview.set_cursor(Gtk.TreePath.new_first()) # TODO: This doesn't fire when the user selects a shortcut folder @Template.Callback() def on_fileentry_file_set(self, fileentry): directory = fileentry.get_file() path = directory.get_path() self.set_location(path) def on_delete_event(self): self.scheduler.remove_all_tasks() self.close_signal.emit(0) return Gtk.ResponseType.OK @Template.Callback() def on_row_activated(self, treeview, path, tvc): it = self.model.get_iter(path) if self.model.iter_has_child(it): if self.treeview.row_expanded(path): self.treeview.collapse_row(path) else: self.treeview.expand_row(path, False) else: path = self.model.get_file_path(it) if not self.model.is_folder(it, 0, path): self.run_diff(path) def run_diff(self, path): if os.path.isdir(path): self.create_diff_signal.emit([Gio.File.new_for_path(path)], {}) return basename = os.path.basename(path) meta = { 'parent': self, 'prompt_resolve': False, } # May have removed directories in list. vc_entry = self.vc.get_entry(path) if vc_entry and vc_entry.state == tree.STATE_CONFLICT and \ hasattr(self.vc, 'get_path_for_conflict'): local_label = _("%s — local") % basename remote_label = _("%s — remote") % basename # We create new temp files for other, base and this, and # then set the output to the current file. if self.props.merge_file_order == "local-merge-remote": conflicts = (tree.CONFLICT_THIS, tree.CONFLICT_MERGED, tree.CONFLICT_OTHER) meta['labels'] = (local_label, None, remote_label) meta['tablabel'] = _("%s (local, merge, remote)") % basename else: conflicts = (tree.CONFLICT_OTHER, tree.CONFLICT_MERGED, tree.CONFLICT_THIS) meta['labels'] = (remote_label, None, local_label) meta['tablabel'] = _("%s (remote, merge, local)") % basename diffs = [ self.vc.get_path_for_conflict(path, conflict=c) for c in conflicts ] temps = [p for p, is_temp in diffs if is_temp] diffs = [p for p, is_temp in diffs] kwargs = { 'auto_merge': False, 'merge_output': Gio.File.new_for_path(path), } meta['prompt_resolve'] = True else: remote_label = _("%s — repository") % basename comp_path = self.vc.get_path_for_repo_file(path) temps = [comp_path] if self.props.left_is_local: diffs = [path, comp_path] meta['labels'] = (None, remote_label) meta['tablabel'] = _("%s (working, repository)") % basename else: diffs = [comp_path, path] meta['labels'] = (remote_label, None) meta['tablabel'] = _("%s (repository, working)") % basename kwargs = {} kwargs['meta'] = meta for temp_file in temps: os.chmod(temp_file, 0o444) _temp_files.append(temp_file) self.create_diff_signal.emit( [Gio.File.new_for_path(d) for d in diffs], kwargs, ) def action_filter_state_change(self, action, value): action.set_state(value) active_filters = [ k for k, (action_name, fn) in self.state_actions.items() if self.get_action_state(action_name) ] if set(active_filters) == set(self.props.status_filters): return self.props.status_filters = active_filters self.refresh() def on_treeview_selection_changed(self, selection=None): if selection is None: selection = self.treeview.get_selection() model, rows = selection.get_selected_rows() paths = [self.model.get_file_path(model.get_iter(r)) for r in rows] states = [self.model.get_state(model.get_iter(r), 0) for r in rows] path_states = dict(zip(paths, states)) valid_actions = self.vc.get_valid_actions(path_states) action_sensitivity = { 'compare': 'compare' in valid_actions, 'vc-add': 'add' in valid_actions, 'vc-commit': 'commit' in valid_actions, 'vc-delete-locally': bool(paths) and self.vc.root not in paths, 'vc-push': 'push' in valid_actions, 'vc-remove': 'remove' in valid_actions, 'vc-resolve': 'resolve' in valid_actions, 'vc-revert': 'revert' in valid_actions, 'vc-update': 'update' in valid_actions, } for action, sensitivity in action_sensitivity.items(): self.set_action_enabled(action, sensitivity) def _get_selected_files(self): model, rows = self.treeview.get_selection().get_selected_rows() sel = [self.model.get_file_path(self.model.get_iter(r)) for r in rows] # Remove empty entries and trailing slashes return [x[-1] != "/" and x or x[:-1] for x in sel if x is not None] def _command_iter(self, command, files, refresh, working_dir): """An iterable that runs a VC command on a set of files This method is intended to be used as a scheduled task, with standard out and error output displayed in this view's consolestream. """ def shelljoin(command): def quote(s): return '"%s"' % s if len(s.split()) > 1 else s return " ".join(quote(tok) for tok in command) files = [os.path.relpath(f, working_dir) for f in files] msg = shelljoin(command + files) + " (in %s)\n" % working_dir self.consolestream.command(msg) readiter = read_pipe_iter(command + files, workdir=working_dir, errorstream=self.consolestream) try: result = next(readiter) while not result: yield 1 result = next(readiter) except IOError as err: error_dialog("Error running command", "While running '%s'\nError: %s" % (msg, err)) result = (1, "") returncode, output = result self.consolestream.output(output + "\n") if returncode: self.console_vbox.show() if refresh: refresh = functools.partial(self.refresh_partial, working_dir) GLib.idle_add(refresh) def has_command(self, command): vc_command = self.command_map.get(command) return vc_command and hasattr(self.vc, vc_command) def command(self, command, files, sync=False): """ Run a command against this view's version control subsystem This is the intended way for things outside of the VCView to call in to version control methods, e.g., to mark a conflict as resolved from a file comparison. :param command: The version control command to run, taken from keys in `VCView.command_map`. :param files: File parameters to the command as paths :param sync: If True, the command will be executed immediately (as opposed to being run by the idle scheduler). """ if not self.has_command(command): log.error("Couldn't understand command %s", command) return if not isinstance(files, list): log.error("Invalid files argument to '%s': %r", command, files) return runner = self.runner if not sync else self.sync_runner command = getattr(self.vc, self.command_map[command]) command(runner, files) def runner(self, command, files, refresh, working_dir): """Schedule a version control command to run as an idle task""" self.scheduler.add_task( self._command_iter(command, files, refresh, working_dir)) def sync_runner(self, command, files, refresh, working_dir): """Run a version control command immediately""" for it in self._command_iter(command, files, refresh, working_dir): pass def action_update(self, *args): self.vc.update(self.runner) def action_push(self, *args): response = PushDialog(self).run() if response == Gtk.ResponseType.OK: self.vc.push(self.runner) def action_commit(self, *args): response, commit_msg = CommitDialog(self).run() if response == Gtk.ResponseType.OK: self.vc.commit(self.runner, self._get_selected_files(), commit_msg) def action_add(self, *args): self.vc.add(self.runner, self._get_selected_files()) def action_remove(self, *args): selected = self._get_selected_files() if any(os.path.isdir(p) for p in selected): # TODO: Improve and reuse this dialog for the non-VC delete action dialog = Gtk.MessageDialog( parent=self.get_toplevel(), flags=(Gtk.DialogFlags.MODAL | Gtk.DialogFlags.DESTROY_WITH_PARENT), type=Gtk.MessageType.WARNING, message_format=_("Remove folder and all its files?")) dialog.format_secondary_text( _("This will remove all selected files and folders, and all " "files within any selected folders, from version control.")) dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) dialog.add_button(_("_Remove"), Gtk.ResponseType.OK) response = dialog.run() dialog.destroy() if response != Gtk.ResponseType.OK: return self.vc.remove(self.runner, selected) def action_resolved(self, *args): self.vc.resolve(self.runner, self._get_selected_files()) def action_revert(self, *args): self.vc.revert(self.runner, self._get_selected_files()) def action_delete(self, *args): files = self._get_selected_files() for name in files: gfile = Gio.File.new_for_path(name) try: trash_or_confirm(gfile) except Exception as e: error_dialog( _("Error deleting {}").format( GLib.markup_escape_text(gfile.get_parse_name()), ), str(e), ) workdir = os.path.dirname(os.path.commonprefix(files)) self.refresh_partial(workdir) def action_diff(self, *args): # TODO: Review the compare/diff action. It doesn't really add much # over activate, since the folder compare doesn't work and hasn't # for... a long time. files = self._get_selected_files() for f in files: self.run_diff(f) def action_open_external(self, *args): self._open_files(self._get_selected_files()) def refresh(self): root = self.model.get_iter_first() if root is None: return self.set_location(self.model.get_file_path(root)) def refresh_partial(self, where): if not self.get_action_state('vc-flatten'): it = self.find_iter_by_name(where) if not it: return path = self.model.get_path(it) self.treeview.grab_focus() self.vc.refresh_vc_state(where) self.scheduler.add_task( self._search_recursively_iter(path, replace=True)) self.scheduler.add_task(self.on_treeview_selection_changed) self.scheduler.add_task(self.on_treeview_cursor_changed) else: # XXX fixme self.refresh() def _update_item_state(self, it, entry): self.model.set_path_state(it, 0, entry.state, entry.isdir) location = Gio.File.new_for_path(self.vc.location) parent = Gio.File.new_for_path(entry.path).get_parent() display_location = location.get_relative_path(parent) self.model.set_value(it, COL_LOCATION, display_location) self.model.set_value(it, COL_STATUS, entry.get_status()) self.model.set_value(it, COL_OPTIONS, entry.options) def on_file_changed(self, filename): it = self.find_iter_by_name(filename) if it: path = self.model.get_file_path(it) self.vc.refresh_vc_state(path) entry = self.vc.get_entry(path) self._update_item_state(it, entry) def find_iter_by_name(self, name): it = self.model.get_iter_first() path = self.model.get_file_path(it) while it: if name == path: return it elif name.startswith(path): child = self.model.iter_children(it) while child: path = self.model.get_file_path(child) if name == path: return child elif name.startswith(path): break else: child = self.model.iter_next(child) it = child else: break return None @Template.Callback() def on_consoleview_populate_popup(self, textview, menu): buf = textview.get_buffer() clear_action = Gtk.MenuItem.new_with_label(_("Clear")) clear_action.connect("activate", lambda *args: buf.delete(*buf.get_bounds())) menu.insert(clear_action, 0) menu.insert(Gtk.SeparatorMenuItem(), 1) menu.show_all() @Template.Callback() def on_treeview_popup_menu(self, treeview): tree.TreeviewCommon.on_treeview_popup_menu(self, treeview) @Template.Callback() def on_treeview_button_press_event(self, treeview, event): tree.TreeviewCommon.on_treeview_button_press_event( self, treeview, event) @Template.Callback() def on_treeview_cursor_changed(self, *args): cursor_path, cursor_col = self.treeview.get_cursor() if not cursor_path: self.set_action_enabled("previous-change", False) self.set_action_enabled("next-change", False) self.current_path = cursor_path return # If invoked directly rather than through a callback, we always check if not args: skip = False else: try: old_cursor = self.model.get_iter(self.current_path) except (ValueError, TypeError): # An invalid path gives ValueError; None gives a TypeError skip = False else: # We can skip recalculation if the new cursor is between # the previous/next bounds, and we weren't on a changed row state = self.model.get_state(old_cursor, 0) if state not in (tree.STATE_NORMAL, tree.STATE_EMPTY): skip = False else: if self.prev_path is None and self.next_path is None: skip = True elif self.prev_path is None: skip = cursor_path < self.next_path elif self.next_path is None: skip = self.prev_path < cursor_path else: skip = self.prev_path < cursor_path < self.next_path if not skip: prev, next_ = self.model._find_next_prev_diff(cursor_path) self.prev_path, self.next_path = prev, next_ self.set_action_enabled("previous-change", prev is not None) self.set_action_enabled("next-change", next_ is not None) self.current_path = cursor_path def next_diff(self, direction): if direction == Gdk.ScrollDirection.UP: path = self.prev_path else: path = self.next_path if path: self.treeview.expand_to_path(path) self.treeview.set_cursor(path) def action_previous_change(self, *args): self.next_diff(Gdk.ScrollDirection.UP) def action_next_change(self, *args): self.next_diff(Gdk.ScrollDirection.DOWN) def action_refresh(self, *args): self.on_fileentry_file_set(self.fileentry) def action_find(self, *args): self.treeview.emit("start-interactive-search") def auto_compare(self): modified_states = (tree.STATE_MODIFIED, tree.STATE_CONFLICT) for it in self.model.state_rows(modified_states): row_paths = self.model.value_paths(it) paths = [p for p in row_paths if os.path.exists(p)] self.run_diff(paths[0])
class PatchDialog(Gtk.Dialog): __gtype_name__ = "PatchDialog" left_radiobutton = Template.Child("left_radiobutton") reverse_checkbutton = Template.Child("reverse_checkbutton") right_radiobutton = Template.Child("right_radiobutton") side_selection_box = Template.Child("side_selection_box") side_selection_label = Template.Child("side_selection_label") textview = Template.Child("textview") def __init__(self, filediff): super().__init__() self.init_template() self.set_transient_for(filediff.get_toplevel()) self.filediff = filediff buf = GtkSource.Buffer() self.textview.set_buffer(buf) lang = LanguageManager.get_language_from_mime_type("text/x-diff") buf.set_language(lang) buf.set_highlight_syntax(True) self.textview.modify_font(meldsettings.font) self.textview.set_editable(False) self.index_map = {self.left_radiobutton: (0, 1), self.right_radiobutton: (1, 2)} self.left_patch = True self.reverse_patch = self.reverse_checkbutton.get_active() if self.filediff.num_panes < 3: self.side_selection_label.hide() self.side_selection_box.hide() meldsettings.connect('changed', self.on_setting_changed) def on_setting_changed(self, setting, key): if key == "font": self.textview.modify_font(meldsettings.font) @Template.Callback() def on_buffer_selection_changed(self, radiobutton): if not radiobutton.get_active(): return self.left_patch = radiobutton == self.left_radiobutton self.update_patch() @Template.Callback() def on_reverse_checkbutton_toggled(self, checkbutton): self.reverse_patch = checkbutton.get_active() self.update_patch() def update_patch(self): indices = (0, 1) if not self.left_patch: indices = (1, 2) if self.reverse_patch: indices = (indices[1], indices[0]) texts = [] for b in self.filediff.textbuffer: start, end = b.get_bounds() text = b.get_text(start, end, False) lines = text.splitlines(True) # Ensure that the last line ends in a newline barelines = text.splitlines(False) if barelines and lines and barelines[-1] == lines[-1]: # Final line lacks a line-break; add in a best guess if len(lines) > 1: previous_linebreak = lines[-2][len(barelines[-2]):] else: previous_linebreak = "\n" lines[-1] += previous_linebreak texts.append(lines) names = [self.filediff.textbuffer[i].data.label for i in range(3)] prefix = os.path.commonprefix(names) names = [n[prefix.rfind("/") + 1:] for n in names] buf = self.textview.get_buffer() text0, text1 = texts[indices[0]], texts[indices[1]] name0, name1 = names[indices[0]], names[indices[1]] diff = difflib.unified_diff(text0, text1, name0, name1) diff_text = "".join(d for d in diff) buf.set_text(diff_text) def save_patch(self, targetfile: Gio.File): buf = self.textview.get_buffer() sourcefile = GtkSource.File.new() saver = GtkSource.FileSaver.new_with_target( buf, sourcefile, targetfile) saver.save_async( GLib.PRIORITY_HIGH, callback=self.file_saved_cb, ) def file_saved_cb(self, saver, result, *args): gfile = saver.get_location() try: saver.save_finish(result) except GLib.Error as err: filename = GLib.markup_escape_text(gfile.get_parse_name()) error_dialog( primary=_("Could not save file %s.") % filename, secondary=_("Couldn’t save file due to:\n%s") % ( GLib.markup_escape_text(str(err))), ) def run(self): self.update_patch() result = super().run() if result < 0: self.hide() return # Copy patch to clipboard if result == 1: buf = self.textview.get_buffer() start, end = buf.get_bounds() clip = Gtk.Clipboard.get_default(Gdk.Display.get_default()) clip.set_text(buf.get_text(start, end, False), -1) clip.store() # Save patch as a file else: gfile = prompt_save_filename(_("Save Patch")) if gfile: self.save_patch(gfile) self.hide()
class NewDiffTab(Gtk.Alignment, LabeledObjectMixin): __gtype_name__ = "NewDiffTab" __gsignals__ = { 'diff-created': (GObject.SignalFlags.RUN_FIRST, None, (object,)), } close_signal = MeldDoc.close_signal label_changed_signal = LabeledObjectMixin.label_changed label_text = _("New comparison") button_compare = Template.Child() button_new_blank = Template.Child() button_type_dir = Template.Child() button_type_file = Template.Child() button_type_vc = Template.Child() choosers_notebook = Template.Child() dir_chooser0 = Template.Child() dir_chooser1 = Template.Child() dir_chooser2 = Template.Child() dir_three_way_checkbutton = Template.Child() file_chooser0 = Template.Child() file_chooser1 = Template.Child() file_chooser2 = Template.Child() file_three_way_checkbutton = Template.Child() filechooserdialog0 = Template.Child() filechooserdialog1 = Template.Child() filechooserdialog2 = Template.Child() vc_chooser0 = Template.Child() def __init__(self, parentapp): super().__init__() self.init_template() map_widgets_into_lists( self, ["file_chooser", "dir_chooser", "vc_chooser", "filechooserdialog"] ) self.button_types = [ self.button_type_file, self.button_type_dir, self.button_type_vc, ] self.diff_methods = { DiffType.File: parentapp.append_filediff, DiffType.Folder: parentapp.append_dirdiff, DiffType.Version: parentapp.append_vcview, } self.diff_type = DiffType.Unselected default_path = GLib.get_home_dir() for chooser in self.file_chooser: chooser.set_current_folder(default_path) self.show() @Template.Callback() def on_button_type_toggled(self, button, *args): if not button.get_active(): if not any([b.get_active() for b in self.button_types]): button.set_active(True) return for b in self.button_types: if b is not button: b.set_active(False) self.diff_type = DiffType(self.button_types.index(button)) self.choosers_notebook.set_current_page(self.diff_type + 1) # FIXME: Add support for new blank for VcView self.button_new_blank.set_sensitive( self.diff_type.supports_blank()) self.button_compare.set_sensitive(True) @Template.Callback() def on_three_way_checkbutton_toggled(self, button, *args): if button is self.file_three_way_checkbutton: self.file_chooser2.set_sensitive(button.get_active()) else: # button is self.dir_three_way_checkbutton self.dir_chooser2.set_sensitive(button.get_active()) @Template.Callback() def on_file_set(self, filechooser, *args): gfile = filechooser.get_file() if not gfile: return parent = gfile.get_parent() if not parent: return if parent.query_file_type( Gio.FileQueryInfoFlags.NONE, None) == Gio.FileType.DIRECTORY: for chooser in self.file_chooser: if not chooser.get_file(): chooser.set_current_folder_file(parent) # TODO: We could do checks here to prevent errors: check to see if # we've got binary files; check for null file selections; sniff text # encodings; check file permissions. def _get_num_paths(self): if self.diff_type in (DiffType.File, DiffType.Folder): three_way_buttons = ( self.file_three_way_checkbutton, self.dir_three_way_checkbutton, ) three_way = three_way_buttons[self.diff_type].get_active() num_paths = 3 if three_way else 2 else: # DiffType.Version num_paths = 1 return num_paths @Template.Callback() def on_button_compare_clicked(self, *args): type_choosers = (self.file_chooser, self.dir_chooser, self.vc_chooser) choosers = type_choosers[self.diff_type][:self._get_num_paths()] compare_gfiles = [chooser.get_file() for chooser in choosers] compare_kwargs = {} if self.diff_type == DiffType.File: chooserdialogs = self.filechooserdialog[:self._get_num_paths()] encodings = [chooser.get_encoding() for chooser in chooserdialogs] compare_kwargs = {'encodings': encodings} tab = self.diff_methods[self.diff_type]( compare_gfiles, **compare_kwargs) recent_comparisons.add(tab) self.emit('diff-created', tab) @Template.Callback() def on_button_new_blank_clicked(self, *args): # TODO: This doesn't work the way I'd like for DirDiff and VCView. # It should do something similar to FileDiff; give a tab with empty # file entries and no comparison done. # File comparison wants None for its paths here. Folder mode # needs an actual directory. if self.diff_type == DiffType.File: gfiles = [None] * self._get_num_paths() else: gfiles = [Gio.File.new_for_path("")] * self._get_num_paths() tab = self.diff_methods[self.diff_type](gfiles) self.emit('diff-created', tab) def on_container_switch_in_event(self, *args): self.label_changed.emit(self.label_text, self.tooltip_text) def on_container_switch_out_event(self, *args): pass def on_delete_event(self, *args): self.close_signal.emit(0) return Gtk.ResponseType.OK
class FindBar(Gtk.Grid): __gtype_name__ = 'FindBar' find_entry = Template.Child() find_next_button = Template.Child() find_previous_button = Template.Child() match_case = Template.Child() regex = Template.Child() replace_all_button = Template.Child() replace_button = Template.Child() replace_entry = Template.Child() whole_word = Template.Child() wrap_box = Template.Child() replace_mode = GObject.Property(type=bool, default=False) @GObject.Signal( name='activate-secondary', flags=(GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION), ) def activate_secondary(self) -> None: self._find_text(backwards=True) def __init__(self, parent): super().__init__() self.init_template() self.search_context = None self.notify_id = None self.set_text_view(None) # Setup a signal for when the find bar loses focus parent.connect('set-focus-child', self.on_focus_child) # Create and bind our GtkSourceSearchSettings settings = GtkSource.SearchSettings() self.match_case.bind_property('active', settings, 'case-sensitive') self.whole_word.bind_property('active', settings, 'at-word-boundaries') self.regex.bind_property('active', settings, 'regex-enabled') self.find_entry.bind_property('text', settings, 'search-text') settings.set_wrap_around(True) self.search_settings = settings # Bind visibility and layout for find-and-replace mode self.bind_property('replace_mode', self.replace_entry, 'visible') self.bind_property('replace_mode', self.replace_all_button, 'visible') self.bind_property('replace_mode', self.replace_button, 'visible') self.bind_property( 'replace_mode', self, 'row-spacing', GObject.BindingFlags.DEFAULT, lambda binding, replace_mode: 6 if replace_mode else 0) def on_focus_child(self, container, widget): if widget is not None: visible = self.props.visible if widget is not self and visible: self.hide() return False def hide(self): self.set_text_view(None) self.wrap_box.set_visible(False) Gtk.Widget.hide(self) def update_match_state(self, *args): # Note that -1 here implies that the search is still running no_matches = (self.search_context.props.occurrences_count == 0 and self.search_settings.props.search_text) style_context = self.find_entry.get_style_context() if no_matches: style_context.add_class(Gtk.STYLE_CLASS_ERROR) else: style_context.remove_class(Gtk.STYLE_CLASS_ERROR) def set_text_view(self, textview): self.textview = textview if textview is not None: self.search_context = GtkSource.SearchContext.new( textview.get_buffer(), self.search_settings) self.search_context.set_highlight(True) self.notify_id = self.search_context.connect( 'notify::occurrences-count', self.update_match_state) else: if self.notify_id: self.search_context.disconnect(self.notify_id) self.notify_id = None self.search_context = None def start_find(self, *, textview: Gtk.TextView, replace: bool, text: str): self.replace_mode = replace self.set_text_view(textview) if text: self.find_entry.set_text(text) self.show() self.find_entry.grab_focus() def start_find_next(self, textview): self.set_text_view(textview) self._find_text() def start_find_previous(self, textview): self.set_text_view(textview) self._find_text(backwards=True) @Template.Callback() def on_find_next_button_clicked(self, button): self._find_text() @Template.Callback() def on_find_previous_button_clicked(self, button): self._find_text(backwards=True) @Template.Callback() def on_replace_button_clicked(self, entry): buf = self.textview.get_buffer() oldsel = buf.get_selection_bounds() match = self._find_text(0) newsel = buf.get_selection_bounds() # Only replace if there is an already-selected match at the cursor if (match and oldsel and oldsel[0].equal(newsel[0]) and oldsel[1].equal(newsel[1])): self.search_context.replace(newsel[0], newsel[1], self.replace_entry.get_text(), -1) self._find_text(0) @Template.Callback() def on_replace_all_button_clicked(self, entry): buf = self.textview.get_buffer() saved_insert = buf.create_mark(None, buf.get_iter_at_mark(buf.get_insert()), True) self.search_context.replace_all(self.replace_entry.get_text(), -1) if not saved_insert.get_deleted(): buf.place_cursor(buf.get_iter_at_mark(saved_insert)) self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5, 0.5) @Template.Callback() def on_toggle_replace_button_clicked(self, button): self.replace_mode = not self.replace_mode @Template.Callback() def on_find_entry_changed(self, entry): self._find_text(0) @Template.Callback() def on_stop_search(self, search_entry): self.hide() def _find_text(self, start_offset=1, backwards=False): if not self.textview or not self.search_context: return buf = self.textview.get_buffer() insert = buf.get_iter_at_mark(buf.get_insert()) start, end = buf.get_bounds() self.wrap_box.set_visible(False) if not backwards: insert.forward_chars(start_offset) match, start_iter, end_iter = self.search_context.forward(insert) if match and (start_iter.get_offset() < insert.get_offset()): self.wrap_box.set_visible(True) else: match, start_iter, end_iter = self.search_context.backward(insert) if match and (start_iter.get_offset() > insert.get_offset()): self.wrap_box.set_visible(True) if match: buf.place_cursor(start_iter) buf.move_mark(buf.get_selection_bound(), end_iter) self.textview.scroll_to_mark(buf.get_insert(), 0.25, True, 0.5, 0.5) return True else: buf.place_cursor(buf.get_iter_at_mark(buf.get_insert())) self.wrap_box.set_visible(False)
class RecentSelector(Gtk.Grid): __gtype_name__ = 'RecentSelector' @GObject.Signal( flags=(GObject.SignalFlags.RUN_FIRST | GObject.SignalFlags.ACTION), arg_types=(str, ), ) def open_recent(self, uri: str) -> None: ... recent_chooser = Template.Child() search_entry = Template.Child() open_button = Template.Child() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.init_template() self.filter_text = '' self.recent_chooser.set_filter(self.make_recent_filter()) def custom_recent_filter_func(self, filter_info: Gtk.RecentFilterInfo) -> bool: """Filter function for Meld-specific files Normal GTK recent filter rules are all OR-ed together to check whether an entry should be shown. This filter instead only ever shows Meld-specific entries, and then filters down from there. """ if filter_info.mime_type != RecentFiles.mime_type: return False if self.filter_text not in filter_info.display_name.lower(): return False return True def make_recent_filter(self) -> Gtk.RecentFilter: recent_filter = Gtk.RecentFilter() recent_filter.add_custom( (Gtk.RecentFilterFlags.MIME_TYPE | Gtk.RecentFilterFlags.DISPLAY_NAME), self.custom_recent_filter_func, ) return recent_filter @Template.Callback() def on_filter_text_changed(self, *args): self.filter_text = self.search_entry.get_text().lower() # This feels unnecessary, but there's no other good way to get # the RecentChooser to re-evaluate the filter. self.recent_chooser.set_filter(self.make_recent_filter()) @Template.Callback() def on_selection_changed(self, *args): have_selection = bool(self.recent_chooser.get_current_uri()) self.open_button.set_sensitive(have_selection) @Template.Callback() def on_activate(self, *args): uri = self.recent_chooser.get_current_uri() if uri: self.open_recent.emit(uri)
class MeldWindow(Gtk.ApplicationWindow): __gtype_name__ = 'MeldWindow' appvbox = Template.Child("appvbox") gear_menu_button = Template.Child("gear_menu_button") notebook = Template.Child("notebook") spinner = Template.Child("spinner") toolbar_holder = Template.Child("toolbar_holder") def __init__(self): super().__init__() self.init_template() actions = ( ("FileMenu", None, _("_File")), ("New", Gtk.STOCK_NEW, _("_New Comparison…"), "<Primary>N", _("Start a new comparison"), self.on_menu_file_new_activate), ("Save", Gtk.STOCK_SAVE, None, None, _("Save the current file"), self.on_menu_save_activate), ("SaveAs", Gtk.STOCK_SAVE_AS, _("Save As…"), "<Primary><shift>S", _("Save the current file with a different name"), self.on_menu_save_as_activate), ("Close", Gtk.STOCK_CLOSE, None, None, _("Close the current file"), self.on_menu_close_activate), ("EditMenu", None, _("_Edit")), ("Undo", Gtk.STOCK_UNDO, None, "<Primary>Z", _("Undo the last action"), self.on_menu_undo_activate), ("Redo", Gtk.STOCK_REDO, None, "<Primary><shift>Z", _("Redo the last undone action"), self.on_menu_redo_activate), ("Cut", Gtk.STOCK_CUT, None, None, _("Cut the selection"), self.on_menu_cut_activate), ("Copy", Gtk.STOCK_COPY, None, None, _("Copy the selection"), self.on_menu_copy_activate), ("Paste", Gtk.STOCK_PASTE, None, None, _("Paste the clipboard"), self.on_menu_paste_activate), ("Find", Gtk.STOCK_FIND, _("Find…"), None, _("Search for text"), self.on_menu_find_activate), ("FindNext", None, _("Find Ne_xt"), "<Primary>G", _("Search forwards for the same text"), self.on_menu_find_next_activate), ("FindPrevious", None, _("Find _Previous"), "<Primary><shift>G", _("Search backwards for the same text"), self.on_menu_find_previous_activate), ("Replace", Gtk.STOCK_FIND_AND_REPLACE, _("_Replace…"), "<Primary>H", _("Find and replace text"), self.on_menu_replace_activate), ("GoToLine", None, _("Go to _Line"), "<Primary>I", _("Go to a specific line"), self.on_menu_go_to_line_activate), ("ChangesMenu", None, _("_Changes")), ("NextChange", Gtk.STOCK_GO_DOWN, _("Next Change"), "<Alt>Down", _("Go to the next change"), self.on_menu_edit_down_activate), ("PrevChange", Gtk.STOCK_GO_UP, _("Previous Change"), "<Alt>Up", _("Go to the previous change"), self.on_menu_edit_up_activate), ("OpenExternal", None, _("Open Externally"), None, _("Open selected file or directory in the default external " "application"), self.on_open_external), ("ViewMenu", None, _("_View")), ("FileStatus", None, _("File Status")), ("VcStatus", None, _("Version Status")), ("FileFilters", None, _("File Filters")), ("Stop", Gtk.STOCK_STOP, None, "Escape", _("Stop the current action"), self.on_toolbar_stop_clicked), ("Refresh", Gtk.STOCK_REFRESH, None, "<Primary>R", _("Refresh the view"), self.on_menu_refresh_activate), ) toggleactions = (("Fullscreen", None, _("Fullscreen"), "F11", _("View the comparison in fullscreen"), self.on_action_fullscreen_toggled, False), ) self.actiongroup = Gtk.ActionGroup(name='MainActions') self.actiongroup.set_translation_domain("meld") self.actiongroup.add_actions(actions) self.actiongroup.add_toggle_actions(toggleactions) recent_action = Gtk.RecentAction(name="Recent", label=_("Open Recent"), tooltip=_("Open recent files"), stock_id=None) recent_action.set_show_private(True) recent_action.set_filter(recent_comparisons.recent_filter) recent_action.set_sort_type(Gtk.RecentSortType.MRU) recent_action.connect("item-activated", self.on_action_recent) self.actiongroup.add_action(recent_action) self.ui = Gtk.UIManager() self.ui.insert_action_group(self.actiongroup, 0) self.ui.add_ui_from_file(ui_file("meldapp-ui.xml")) for menuitem in ("Save", "Undo"): self.actiongroup.get_action(menuitem).props.is_important = True self.add_accel_group(self.ui.get_accel_group()) self.menubar = self.ui.get_widget('/Menubar') self.toolbar = self.ui.get_widget('/Toolbar') self.toolbar.get_style_context().add_class( Gtk.STYLE_CLASS_PRIMARY_TOOLBAR) # Alternate keybindings for a few commands. extra_accels = ( ("<Primary>D", self.on_menu_edit_down_activate), ("<Primary>E", self.on_menu_edit_up_activate), ("<Alt>KP_Down", self.on_menu_edit_down_activate), ("<Alt>KP_Up", self.on_menu_edit_up_activate), ("F5", self.on_menu_refresh_activate), ) accel_group = self.ui.get_accel_group() for accel, callback in extra_accels: keyval, mask = Gtk.accelerator_parse(accel) accel_group.connect(keyval, mask, 0, callback) # Initialise sensitivity for important actions self.actiongroup.get_action("Stop").set_sensitive(False) self._update_page_action_sensitivity() self.appvbox.pack_start(self.menubar, False, True, 0) self.toolbar_holder.pack_start(self.toolbar, True, True, 0) # This double toolbar works around integrating non-UIManager widgets # into the toolbar. It's no longer used, but kept as a possible # GAction porting helper. self.secondary_toolbar = Gtk.Toolbar() self.secondary_toolbar.get_style_context().add_class( Gtk.STYLE_CLASS_PRIMARY_TOOLBAR) self.toolbar_holder.pack_end(self.secondary_toolbar, False, True, 0) self.secondary_toolbar.show_all() # Manually handle GAction additions actions = (("close", self.on_menu_close_activate, None), ) for (name, callback, accel) in actions: action = Gio.SimpleAction.new(name, None) action.connect('activate', callback) self.add_action(action) # Fake out the spinner on Windows. See Gitlab issue #133. if os.name == 'nt': for attr in ('stop', 'hide', 'show', 'start'): setattr(self.spinner, attr, lambda *args: True) self.drag_dest_set( Gtk.DestDefaults.MOTION | Gtk.DestDefaults.HIGHLIGHT | Gtk.DestDefaults.DROP, None, Gdk.DragAction.COPY) self.drag_dest_add_uri_targets() self.connect("drag_data_received", self.on_widget_drag_data_received) self.window_state = SavedWindowState() self.window_state.bind(self) self.should_close = False self.idle_hooked = 0 self.scheduler = LifoScheduler() self.scheduler.connect("runnable", self.on_scheduler_runnable) self.ui.ensure_update() self.diff_handler = None self.undo_handlers = tuple() # Set tooltip on map because the recentmenu is lazily created rmenu = self.ui.get_widget('/Menubar/FileMenu/Recent').get_submenu() rmenu.connect("map", self._on_recentmenu_map) def do_realize(self): Gtk.ApplicationWindow.do_realize(self) app = self.get_application() menu = app.get_menu_by_id("gear-menu") self.gear_menu_button.set_popover( Gtk.Popover.new_from_model(self.gear_menu_button, menu)) meld.ui.util.extract_accels_from_menu(menu, self.get_application()) def _on_recentmenu_map(self, recentmenu): for imagemenuitem in recentmenu.get_children(): imagemenuitem.set_tooltip_text(imagemenuitem.get_label()) def on_widget_drag_data_received(self, wid, context, x, y, selection_data, info, time): uris = selection_data.get_uris() if uris: self.open_paths([Gio.File.new_for_uri(uri) for uri in uris]) return True def on_idle(self): ret = self.scheduler.iteration() if ret and isinstance(ret, str): self.spinner.set_tooltip_text(ret) pending = self.scheduler.tasks_pending() if not pending: self.spinner.stop() self.spinner.hide() self.spinner.set_tooltip_text("") self.idle_hooked = None self.actiongroup.get_action("Stop").set_sensitive(False) return pending def on_scheduler_runnable(self, sched): if not self.idle_hooked: self.spinner.show() self.spinner.start() self.actiongroup.get_action("Stop").set_sensitive(True) self.idle_hooked = GLib.idle_add(self.on_idle) @Template.Callback() def on_delete_event(self, *extra): should_cancel = False # Delete pages from right-to-left. This ensures that if a version # control page is open in the far left page, it will be closed last. for page in reversed(self.notebook.get_children()): self.notebook.set_current_page(self.notebook.page_num(page)) response = page.on_delete_event() if response == Gtk.ResponseType.CANCEL: should_cancel = True should_cancel = should_cancel or self.has_pages() if should_cancel: self.should_close = True return should_cancel def has_pages(self): return self.notebook.get_n_pages() > 0 def _update_page_action_sensitivity(self): current_page = self.notebook.get_current_page() if current_page != -1: page = self.notebook.get_nth_page(current_page) else: page = None self.actiongroup.get_action("Close").set_sensitive(bool(page)) if not isinstance(page, MeldDoc): for action in ("PrevChange", "NextChange", "Cut", "Copy", "Paste", "Find", "FindNext", "FindPrevious", "Replace", "Refresh", "GoToLine"): self.actiongroup.get_action(action).set_sensitive(False) else: for action in ("Find", "Refresh"): self.actiongroup.get_action(action).set_sensitive(True) is_filediff = isinstance(page, FileDiff) for action in ("Cut", "Copy", "Paste", "FindNext", "FindPrevious", "Replace", "GoToLine"): self.actiongroup.get_action(action).set_sensitive(is_filediff) def handle_current_doc_switch(self, page): if self.diff_handler is not None: page.disconnect(self.diff_handler) page.on_container_switch_out_event(self.ui) if self.undo_handlers: undoseq = page.undosequence for handler in self.undo_handlers: undoseq.disconnect(handler) self.undo_handlers = tuple() @Template.Callback() def on_switch_page(self, notebook, page, which): oldidx = notebook.get_current_page() if oldidx >= 0: olddoc = notebook.get_nth_page(oldidx) self.handle_current_doc_switch(olddoc) newdoc = notebook.get_nth_page(which) if which >= 0 else None try: undoseq = newdoc.undosequence can_undo = undoseq.can_undo() can_redo = undoseq.can_redo() undo_handler = undoseq.connect("can-undo", self.on_can_undo) redo_handler = undoseq.connect("can-redo", self.on_can_redo) self.undo_handlers = (undo_handler, redo_handler) except AttributeError: can_undo, can_redo = False, False self.actiongroup.get_action("Undo").set_sensitive(can_undo) self.actiongroup.get_action("Redo").set_sensitive(can_redo) # FileDiff handles save sensitivity; it makes no sense for other modes if not isinstance(newdoc, FileDiff): self.actiongroup.get_action("Save").set_sensitive(False) self.actiongroup.get_action("SaveAs").set_sensitive(False) else: self.actiongroup.get_action("SaveAs").set_sensitive(True) if newdoc: nbl = self.notebook.get_tab_label(newdoc) self.set_title(nbl.props.label_text) else: self.set_title("Meld") if isinstance(newdoc, MeldDoc): self.diff_handler = newdoc.next_diff_changed_signal.connect( self.on_next_diff_changed) else: self.diff_handler = None if hasattr(newdoc, 'scheduler'): self.scheduler.add_task(newdoc.scheduler) @Template.Callback() def after_switch_page(self, notebook, page, which): newdoc = notebook.get_nth_page(which) newdoc.on_container_switch_in_event(self.ui) self._update_page_action_sensitivity() @Template.Callback() def after_page_reordered(self, notebook, page, page_num): self._update_page_action_sensitivity() @Template.Callback() def on_page_label_changed(self, notebook, label_text): self.set_title(label_text) def on_can_undo(self, undosequence, can): self.actiongroup.get_action("Undo").set_sensitive(can) def on_can_redo(self, undosequence, can): self.actiongroup.get_action("Redo").set_sensitive(can) def on_next_diff_changed(self, doc, have_prev, have_next): self.actiongroup.get_action("PrevChange").set_sensitive(have_prev) self.actiongroup.get_action("NextChange").set_sensitive(have_next) def on_menu_file_new_activate(self, menuitem): self.append_new_comparison() def on_menu_save_activate(self, menuitem): self.current_doc().save() def on_menu_save_as_activate(self, menuitem): self.current_doc().save_as() def on_action_recent(self, action): uri = action.get_current_uri() if not uri: return try: self.append_recent(uri) except (IOError, ValueError): # FIXME: Need error handling, but no sensible display location pass def on_menu_close_activate(self, *extra): i = self.notebook.get_current_page() if i >= 0: page = self.notebook.get_nth_page(i) page.on_delete_event() def on_menu_undo_activate(self, *extra): self.current_doc().on_undo_activate() def on_menu_redo_activate(self, *extra): self.current_doc().on_redo_activate() def on_menu_refresh_activate(self, *extra): self.current_doc().on_refresh_activate() def on_menu_find_activate(self, *extra): self.current_doc().on_find_activate() def on_menu_find_next_activate(self, *extra): self.current_doc().on_find_next_activate() def on_menu_find_previous_activate(self, *extra): self.current_doc().on_find_previous_activate() def on_menu_replace_activate(self, *extra): self.current_doc().on_replace_activate() def on_menu_go_to_line_activate(self, *extra): self.current_doc().on_go_to_line_activate() def on_menu_copy_activate(self, *extra): widget = self.get_focus() if isinstance(widget, Gtk.Editable): widget.copy_clipboard() elif isinstance(widget, Gtk.TextView): widget.emit("copy-clipboard") def on_menu_cut_activate(self, *extra): widget = self.get_focus() if isinstance(widget, Gtk.Editable): widget.cut_clipboard() elif isinstance(widget, Gtk.TextView): widget.emit("cut-clipboard") def on_menu_paste_activate(self, *extra): widget = self.get_focus() if isinstance(widget, Gtk.Editable): widget.paste_clipboard() elif isinstance(widget, Gtk.TextView): widget.emit("paste-clipboard") def on_action_fullscreen_toggled(self, widget): window_state = self.get_window().get_state() is_full = window_state & Gdk.WindowState.FULLSCREEN if widget.get_active() and not is_full: self.fullscreen() elif is_full: self.unfullscreen() def on_menu_edit_down_activate(self, *args): self.current_doc().next_diff(Gdk.ScrollDirection.DOWN) def on_menu_edit_up_activate(self, *args): self.current_doc().next_diff(Gdk.ScrollDirection.UP) def on_open_external(self, *args): self.current_doc().open_external() def on_toolbar_stop_clicked(self, *args): doc = self.current_doc() if doc.scheduler.tasks_pending(): doc.scheduler.remove_task(doc.scheduler.get_current_task()) def page_removed(self, page, status): if hasattr(page, 'scheduler'): self.scheduler.remove_scheduler(page.scheduler) page_num = self.notebook.page_num(page) if self.notebook.get_current_page() == page_num: self.handle_current_doc_switch(page) self.notebook.remove_page(page_num) # Normal switch-page handlers don't get run for removing the # last page from a notebook. if not self.has_pages(): self.on_switch_page(self.notebook, page, -1) self._update_page_action_sensitivity() # Synchronise UIManager state; this shouldn't be necessary, # but upstream aren't touching UIManager bugs. self.ui.ensure_update() if self.should_close: cancelled = self.emit('delete-event', Gdk.Event.new(Gdk.EventType.DELETE)) if not cancelled: self.emit('destroy') def on_page_state_changed(self, page, old_state, new_state): if self.should_close and old_state == ComparisonState.Closing: # Cancel closing if one of our tabs does self.should_close = False def on_file_changed(self, srcpage, filename): for page in self.notebook.get_children(): if page != srcpage: page.on_file_changed(filename) def _append_page(self, page, icon): nbl = NotebookLabel(icon_name=icon, page=page) self.notebook.append_page(page, nbl) self.notebook.child_set_property(page, 'tab-expand', True) # Change focus to the newly created page only if the user is on a # DirDiff or VcView page, or if it's a new tab page. This prevents # cycling through X pages when X diffs are initiated. if isinstance(self.current_doc(), DirDiff) or \ isinstance(self.current_doc(), VcView) or \ isinstance(page, NewDiffTab): self.notebook.set_current_page(self.notebook.page_num(page)) if hasattr(page, 'scheduler'): self.scheduler.add_scheduler(page.scheduler) if isinstance(page, MeldDoc): page.file_changed_signal.connect(self.on_file_changed) page.create_diff_signal.connect( lambda obj, arg, kwargs: self.append_diff(arg, **kwargs)) page.tab_state_changed.connect(self.on_page_state_changed) page.close_signal.connect(self.page_removed) self.notebook.set_tab_reorderable(page, True) def append_new_comparison(self): doc = NewDiffTab(self) self._append_page(doc, "document-new") self.notebook.on_label_changed(doc, _("New comparison"), None) def diff_created_cb(doc, newdoc): doc.on_delete_event() idx = self.notebook.page_num(newdoc) self.notebook.set_current_page(idx) doc.connect("diff-created", diff_created_cb) return doc def append_dirdiff(self, gfiles, auto_compare=False): dirs = [d.get_path() if d else None for d in gfiles] assert len(dirs) in (1, 2, 3) doc = DirDiff(len(dirs)) self._append_page(doc, "folder") doc.set_locations(dirs) if auto_compare: doc.scheduler.add_task(doc.auto_compare) return doc def append_filediff(self, gfiles, *, encodings=None, merge_output=None, meta=None): assert len(gfiles) in (1, 2, 3) doc = FileDiff(len(gfiles)) self._append_page(doc, "text-x-generic") doc.set_files(gfiles, encodings) if merge_output is not None: doc.set_merge_output_file(merge_output) if meta is not None: doc.set_meta(meta) return doc def append_filemerge(self, gfiles, merge_output=None): if len(gfiles) != 3: raise ValueError( _("Need three files to auto-merge, got: %r") % [f.get_parse_name() for f in gfiles]) doc = FileMerge(len(gfiles)) self._append_page(doc, "text-x-generic") doc.set_files(gfiles) if merge_output is not None: doc.set_merge_output_file(merge_output) return doc def append_diff(self, gfiles, auto_compare=False, auto_merge=False, merge_output=None, meta=None): have_directories = False have_files = False for f in gfiles: if f.query_file_type(Gio.FileQueryInfoFlags.NONE, None) == Gio.FileType.DIRECTORY: have_directories = True else: have_files = True if have_directories and have_files: raise ValueError( _("Cannot compare a mixture of files and directories")) elif have_directories: return self.append_dirdiff(gfiles, auto_compare) elif auto_merge: return self.append_filemerge(gfiles, merge_output=merge_output) else: return self.append_filediff(gfiles, merge_output=merge_output, meta=meta) def append_vcview(self, location, auto_compare=False): doc = VcView() self._append_page(doc, "meld-version-control") if isinstance(location, (list, tuple)): location = location[0] doc.set_location(location.get_path()) if auto_compare: doc.scheduler.add_task(doc.auto_compare) return doc def append_recent(self, uri): comparison_type, gfiles = recent_comparisons.read(uri) comparison_method = { RecentType.File: self.append_filediff, RecentType.Folder: self.append_dirdiff, RecentType.Merge: self.append_filemerge, RecentType.VersionControl: self.append_vcview, } tab = comparison_method[comparison_type](gfiles) self.notebook.set_current_page(self.notebook.page_num(tab)) recent_comparisons.add(tab) return tab def _single_file_open(self, gfile): doc = VcView() def cleanup(): self.scheduler.remove_scheduler(doc.scheduler) self.scheduler.add_task(cleanup) self.scheduler.add_scheduler(doc.scheduler) path = gfile.get_path() doc.set_location(path) doc.create_diff_signal.connect( lambda obj, arg, kwargs: self.append_diff(arg, **kwargs)) doc.run_diff(path) def open_paths(self, gfiles, auto_compare=False, auto_merge=False, focus=False): tab = None if len(gfiles) == 1: a = gfiles[0] if a.query_file_type(Gio.FileQueryInfoFlags.NONE, None) == \ Gio.FileType.DIRECTORY: tab = self.append_vcview(a, auto_compare) else: self._single_file_open(a) elif len(gfiles) in (2, 3): tab = self.append_diff(gfiles, auto_compare=auto_compare, auto_merge=auto_merge) if tab: recent_comparisons.add(tab) if focus: self.notebook.set_current_page(self.notebook.page_num(tab)) return tab def current_doc(self): "Get the current doc or a dummy object if there is no current" index = self.notebook.get_current_page() if index >= 0: page = self.notebook.get_nth_page(index) if isinstance(page, MeldDoc): return page class DummyDoc: def __getattr__(self, a): return lambda *x: None return DummyDoc()