diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b7995..ce371f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Translation: updated Swedish translation (@eson57) - Dialog: prompt only once if several files needs to be reloaded (@yuriiz) - Use GTK3's Gtk.Application/Gtk.ApplicationWindow (@yuriiz) +- Split `DiffuseApplication` and `DiffuseWindow` classes in their own files (@MightyCreak) ### Fixed diff --git a/src/diffuse/main.py b/src/diffuse/main.py index cd65d1a..589703e 100644 --- a/src/diffuse/main.py +++ b/src/diffuse/main.py @@ -19,1807 +19,28 @@ import os import sys -import codecs import encodings -import shlex -import stat -import webbrowser from gettext import gettext as _ -from typing import List, Optional -from urllib.parse import urlparse -from diffuse import constants -from diffuse import utils -from diffuse.dialogs import AboutDialog, FileChooserDialog, NumericDialog, SearchDialog -from diffuse.preferences import Preferences +from diffuse import constants, utils from diffuse.resources import theResources -from diffuse.utils import LineEnding -from diffuse.vcs.vcs_interface import VcsInterface -from diffuse.vcs.vcs_registry import VcsRegistry -from diffuse.widgets import FileDiffViewerBase, EditMode +from diffuse.window import DiffuseWindow import gi # type: ignore -gi.require_version('GObject', '2.0') gi.require_version('Gtk', '3.0') -gi.require_version('Gdk', '3.0') -gi.require_version('GdkPixbuf', '2.0') -gi.require_version('Pango', '1.0') -gi.require_version('PangoCairo', '1.0') -from gi.repository import (Gdk, GdkPixbuf, Gio, # type: ignore # noqa: E402 - GLib, GObject, Gtk, Pango) +from gi.repository import Gio, GLib, Gtk # type: ignore # noqa: E402 -theVCSs = VcsRegistry() +class DiffuseApplication(Gtk.Application): + """The main application class.""" -# widget classed to create notebook tabs with labels and a close button -# use notebooktab.button.connect() to be notified when the button is pressed -# make this a Gtk.EventBox so signals can be connected for MMB and RMB button -# presses. -class NotebookTab(Gtk.EventBox): - def __init__(self, name: str, stock: str) -> None: - Gtk.EventBox.__init__(self) - self.set_visible_window(False) - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - if stock is not None: - image = Gtk.Image() - image.set_from_stock(stock, Gtk.IconSize.MENU) - hbox.pack_start(image, False, False, 5) - image.show() - - label = Gtk.Label(label=name) - # left justify the widget - label.set_xalign(0.0) - label.set_yalign(0.5) - hbox.pack_start(label, True, True, 0) - label.show() - self.label = label - - button = Gtk.Button() - button.set_relief(Gtk.ReliefStyle.NONE) - image = Gtk.Image() - image.set_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU) - button.add(image) - image.show() - button.set_tooltip_text(_('Close Tab')) - hbox.pack_start(button, False, False, 0) - button.show() - self.button = button - - self.add(hbox) - hbox.show() - - def get_text(self) -> str: - return self.label.get_text() - - def set_text(self, s: str) -> None: - self.label.set_text(s) - - -# contains information about a file -class FileInfo: - def __init__(self, name=None, encoding=None, vcs=None, revision=None, label=None): - # file name - self.name = name - # name of codec used to translate the file contents to unicode text - self.encoding = encoding - # the VCS object - self.vcs: VcsInterface = vcs - # revision used to retrieve file from the VCS - self.revision = revision - # alternate text to display instead of the actual file name - self.label = label - # 'stat' for files read from disk -- used to warn about changes to the - # file on disk before saving - self.stat = None - # most recent 'stat' for files read from disk -- used on focus change - # to warn about changes to file on disk - self.last_stat = None - - -# the main application class containing a set of file viewers -# this class displays tab for switching between viewers and dispatches menu -# commands to the current viewer -class Diffuse(Gtk.ApplicationWindow): - # specialization of FileDiffViewerBase for Diffuse - class FileDiffViewer(FileDiffViewerBase): - # pane header - class PaneHeader(Gtk.Box): - def __init__(self) -> None: - Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - _append_buttons(self, Gtk.IconSize.MENU, [ - [Gtk.STOCK_OPEN, self.button_cb, 'open', _('Open File...')], - [Gtk.STOCK_REFRESH, self.button_cb, 'reload', _('Reload File')], - [Gtk.STOCK_SAVE, self.button_cb, 'save', _('Save File')], - [Gtk.STOCK_SAVE_AS, self.button_cb, 'save_as', _('Save File As...')]]) - - self.label = label = Gtk.Label() - label.set_selectable(True) - label.set_ellipsize(Pango.EllipsizeMode.START) - label.set_max_width_chars(1) - - self.pack_start(label, True, True, 0) - - # file's name and information about how to retrieve it from a - # VCS - self.info = FileInfo() - self.has_edits = False - self.updateTitle() - self.show_all() - - # callback for buttons - def button_cb(self, widget, s): - self.emit(s) - - # creates an appropriate title for the pane header - def updateTitle(self) -> None: - ss = [] - info = self.info - if info.label is not None: - # show the provided label instead of the file name - ss.append(info.label) - else: - if info.name is not None: - ss.append(info.name) - if info.revision is not None: - ss.append('(' + info.revision + ')') - if self.has_edits: - ss.append('*') - s = ' '.join(ss) - self.label.set_text(s) - self.label.set_tooltip_text(s) - self.emit('title_changed') - - # set num edits - def setEdits(self, has_edits: bool) -> None: - if self.has_edits != has_edits: - self.has_edits = has_edits - self.updateTitle() - - # Has the file on disk changed since last time it was loaded? - def has_file_changed_on_disk(self) -> bool: - if self.info.last_stat is not None: - try: - new_stat = os.stat(self.info.name) - if self.info.last_stat[stat.ST_MTIME] < new_stat[stat.ST_MTIME]: - # update our notion of the most recent modification - self.info.last_stat = new_stat - return True - except OSError: - return False - return False - - # pane footer - class PaneFooter(Gtk.Box): - def __init__(self) -> None: - Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - self.cursor = label = Gtk.Label() - self.cursor.set_size_request(-1, -1) - self.pack_start(label, False, False, 0) - - separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - self.pack_end(separator, False, False, 10) - - self.encoding = label = Gtk.Label() - self.pack_end(label, False, False, 0) - - separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - self.pack_end(separator, False, False, 10) - - self.format = label = Gtk.Label() - self.pack_end(label, False, False, 0) - - separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - self.pack_end(separator, False, False, 10) - - self.set_size_request(0, self.get_size_request()[1]) - self.show_all() - - # set the cursor label - def updateCursor(self, viewer: FileDiffViewerBase, f: int) -> None: - if viewer.mode == EditMode.CHAR and viewer.current_pane == f: - # # TODO: Find a fix for the column bug (resizing issue when editing a line) - # j = viewer.current_char - # if j > 0: - # text = viewer.getLineText(viewer.current_pane, viewer.current_line)[:j] - # j = viewer.stringWidth(text) - # s = _('Column %d') % (j, ) - # - # In the meantime, keep this line of code in order to keep the translations - s = _('Column %d') % (0, ) - s = '' - else: - s = '' - self.cursor.set_text(s) - - # set the format label - def setFormat(self, s: LineEnding) -> None: - v = [] - if s & LineEnding.DOS_FORMAT: - v.append('DOS') - if s & LineEnding.MAC_FORMAT: - v.append('Mac') - if s & LineEnding.UNIX_FORMAT: - v.append('Unix') - self.format.set_text('/'.join(v)) - - # set the format label - def setEncoding(self, s: str) -> None: - if s is None: - s = '' - self.encoding.set_text(s) - - def __init__(self, n: int, prefs: Preferences, title: str) -> None: - super().__init__(n, prefs) - - self.title = title - self.status: Optional[str] = '' - - self.headers: List[Diffuse.FileDiffViewer.PaneHeader] = [] - self.footers: List[Diffuse.FileDiffViewer.PaneFooter] = [] - for i in range(n): - # pane header - w = Diffuse.FileDiffViewer.PaneHeader() - self.headers.append(w) - self.attach(w, i, 0, 1, 1) - w.connect('title-changed', self.title_changed_cb) - w.connect('open', self.open_file_button_cb, i) - w.connect('reload', self.reload_file_button_cb, i) - w.connect('save', self.save_file_button_cb, i) - w.connect('save-as', self.save_file_as_button_cb, i) - w.show() - - # pane footer - w = Diffuse.FileDiffViewer.PaneFooter() - self.footers.append(w) - self.attach(w, i, 2, 1, 1) - w.show() - - self.connect('swapped-panes', self.swapped_panes_cb) - self.connect('num-edits-changed', self.num_edits_changed_cb) - self.connect('mode-changed', self.mode_changed_cb) - self.connect('cursor-changed', self.cursor_changed_cb) - self.connect('format-changed', self.format_changed_cb) - - for i, darea in enumerate(self.dareas): - darea.drag_dest_set( - Gtk.DestDefaults.ALL, - [Gtk.TargetEntry.new('text/uri-list', 0, 0)], - Gdk.DragAction.COPY - ) - darea.connect('drag-data-received', self.drag_data_received_cb, i) - # initialise status - self.updateStatus() - - # convenience method to request confirmation before loading a file if - # it will cause existing edits to be lost - def loadFromInfo(self, f: int, info: FileInfo) -> None: - if self.headers[f].has_edits: - # warn users of any unsaved changes they might lose - dialog = Gtk.MessageDialog( - self.get_toplevel(), - Gtk.DialogFlags.DESTROY_WITH_PARENT, - Gtk.MessageType.WARNING, - Gtk.ButtonsType.NONE, _('Save changes before loading the new file?') - ) - dialog.set_title(constants.APP_NAME) - dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) - dialog.add_button(Gtk.STOCK_NO, Gtk.ResponseType.REJECT) - dialog.add_button(Gtk.STOCK_YES, Gtk.ResponseType.OK) - dialog.set_default_response(Gtk.ResponseType.CANCEL) - response = dialog.run() - dialog.destroy() - if response == Gtk.ResponseType.OK: - # save the current pane contents - if not self.save_file(f): - # cancel if the save failed - return - elif response != Gtk.ResponseType.REJECT: - # cancel if the user did not choose 'yes' or 'no' - return - self.openUndoBlock() - self.recordEditMode() - self.load(f, info) - self.recordEditMode() - self.closeUndoBlock() - - # callback used when receiving drag-n-drop data - def drag_data_received_cb(self, widget, context, x, y, selection, targettype, eventtime, f): - # get uri list - uris = selection.get_uris() - # load the first valid file - for uri in uris: - path = urlparse(uri).path - if os.path.isfile(path): - self.loadFromInfo(f, FileInfo(path)) - break - - # change the file info for pane 'f' to 'info' - def setFileInfo(self, f, info): - h, footer = self.headers[f], self.footers[f] - h.info = info - h.updateTitle() - footer.setFormat(self.panes[f].format) - footer.setEncoding(info.encoding) - footer.updateCursor(self, f) - - # callback used when a pane header's title changes - def title_changed_cb(self, widget): - # choose a short but descriptive title for the viewer - has_edits = False - names = [] - unique_names = set() - for header in self.headers: - has_edits |= header.has_edits - s = header.info.label - if s is None: - # no label provided, show the file name instead - s = header.info.name - if s is not None: - s = os.path.basename(s) - if s is not None: - names.append(s) - unique_names.add(s) - - if len(unique_names) > 0: - if len(unique_names) == 1: - self.title = names[0] - else: - self.title = ' : '.join(names) - s = self.title - if has_edits: - s += ' *' - self.emit('title_changed', s) - - def setEncoding(self, f, encoding): - h = self.headers[f] - h.info.encoding = encoding - self.footers[f].setEncoding(encoding) - - # load a new file into pane 'f' - # 'info' indicates the name of the file and how to retrieve it from the - # version control system if applicable - def load(self, f: int, info: FileInfo) -> None: - name = info.name - encoding = info.encoding - stat = None - if name is None: - # reset to an empty pane - ss = [] - else: - rev = info.revision - try: - if rev is None: - # load the contents of a plain file - with open(name, 'rb') as fd: - contents = fd.read() - # get the file's modification times so we can detect changes - stat = os.stat(name) - else: - if info.vcs is None: - raise IOError('Not under version control.') - fullname = os.path.abspath(name) - # retrieve the revision from the version control system - contents = info.vcs.getRevision(self.prefs, fullname, rev) - # convert file contents to unicode - if encoding is None: - s, encoding = self.prefs.convertToUnicode(contents) - else: - s = str(contents, encoding=encoding) - ss = utils.splitlines(s) - except (IOError, OSError, UnicodeDecodeError, LookupError): - # FIXME: this can occur before the toplevel window is drawn - if rev is not None: - msg = _( - 'Error reading revision %(rev)s of %(file)s.' - ) % {'rev': rev, 'file': name} - else: - msg = _('Error reading %s.') % (name, ) - utils.logErrorAndDialog(msg, self.get_toplevel()) - return - # update the panes contents, last modified time, and title - self.replaceContents(f, ss) - info.encoding = encoding - info.last_stat = info.stat = stat - self.setFileInfo(f, info) - # use the file name to choose appropriate syntax highlighting rules - if name is not None: - syntax = theResources.guessSyntaxForFile(name, ss) - if syntax is not None: - self.setSyntax(syntax) - - # load a new file into pane 'f' - def open_file(self, f: int, reload: bool = False) -> None: - h = self.headers[f] - info = h.info - if not reload: - # we need to ask for a file name if we are not reloading the - # existing file - dialog = FileChooserDialog( - _('Open File'), - self.get_toplevel(), - self.prefs, - Gtk.FileChooserAction.OPEN, - Gtk.STOCK_OPEN, - True - ) - if info.name is not None: - dialog.set_filename(os.path.realpath(info.name)) - dialog.set_encoding(info.encoding) - dialog.set_default_response(Gtk.ResponseType.OK) - end = (dialog.run() != Gtk.ResponseType.OK) - name = dialog.get_filename() - rev = None - vcs = None - revision = dialog.get_revision().strip() - if revision != '': - rev = revision - vcs = theVCSs.findByFilename(name, self.prefs) - info = FileInfo(name, dialog.get_encoding(), vcs, rev) - dialog.destroy() - if end: - return - self.loadFromInfo(f, info) - - # callback for open file button - def open_file_button_cb(self, widget, f): - self.open_file(f) - - # callback for open file menu item - def open_file_cb(self, widget, data): - self.open_file(self.current_pane) - - # callback for reload file button - def reload_file_button_cb(self, widget, f): - self.open_file(f, True) - - # callback for reload file menu item - def reload_file_cb(self, widget, data): - self.open_file(self.current_pane, True) - - # save contents of pane 'f' to file - def save_file(self, f: int, save_as: bool = False) -> bool: - h = self.headers[f] - info = h.info - name, encoding, rev, label = info.name, info.encoding, info.revision, info.label - if name is None or rev is not None: - # we need to prompt for a file name the current contents were - # not loaded from a regular file - save_as = True - if save_as: - # prompt for a file name - dialog = FileChooserDialog( - _('Save %(title)s Pane %(pane)d') % {'title': self.title, 'pane': f + 1}, - self.get_toplevel(), - self.prefs, - Gtk.FileChooserAction.SAVE, - Gtk.STOCK_SAVE - ) - if name is not None: - dialog.set_filename(os.path.abspath(name)) - if encoding is None: - encoding = self.prefs.getDefaultEncoding() - dialog.set_encoding(encoding) - name, label = None, None - dialog.set_default_response(Gtk.ResponseType.OK) - if dialog.run() == Gtk.ResponseType.OK: - name = dialog.get_filename() - encoding = dialog.get_encoding() - if encoding is None: - if info.encoding is not None: - # this case can occur if the user provided the - # encoding and it is not an encoding we know about - encoding = info.encoding - else: - encoding = self.prefs.getDefaultEncoding() - dialog.destroy() - if name is None: - return False - try: - msg = None - # warn if we are about to overwrite an existing file - if save_as: - if os.path.exists(name): - msg = _( - 'A file named %s already exists. Do you want to overwrite it?' - ) % (name, ) - # warn if we are about to overwrite a file that has changed - # since we last read it - elif info.stat is not None: - if info.stat[stat.ST_MTIME] < os.stat(name)[stat.ST_MTIME]: - msg = _( - 'The file %s has been modified by another process since reading it. ' - 'If you save, all the external changes could be lost. Save anyways?' - ) % (name, ) - if msg is not None: - dialog = utils.MessageDialog(self.get_toplevel(), Gtk.MessageType.QUESTION, msg) - end = (dialog.run() != Gtk.ResponseType.OK) - dialog.destroy() - if end: - return False - except OSError: - pass - try: - # convert the text to the output encoding - # refresh the lines to contain new objects with updated line - # numbers and no local edits - ss = [] - for line in self.panes[f].lines: - if line is not None: - s = line.getText() - if s is not None: - ss.append(s) - encoded = codecs.encode(''.join(ss), encoding) - - # write file - with open(name, 'wb') as fd: - fd.write(encoded) - - # make the edits look permanent - self.openUndoBlock() - self.bakeEdits(f) - self.closeUndoBlock() - # update the pane file info - info.name, info.encoding, info.revision, info.label = name, encoding, None, label - info.last_stat = info.stat = os.stat(name) - self.setFileInfo(f, info) - # update the syntax highlighting in case we changed the file extension - syntax = theResources.guessSyntaxForFile(name, ss) - if syntax is not None: - self.setSyntax(syntax) - return True - except (UnicodeEncodeError, LookupError): - utils.logErrorAndDialog( - _('Error encoding to %s.') % (encoding, ), - self.get_toplevel() - ) - except IOError: - utils.logErrorAndDialog(_('Error writing %s.') % (name, ), self.get_toplevel()) - return False - - # callback for save file menu item - def save_file_cb(self, widget, data): - self.save_file(self.current_pane) - - # callback for save file as menu item - def save_file_as_cb(self, widget, data): - self.save_file(self.current_pane, True) - - # callback for save all menu item - def save_all_cb(self, widget, data): - for f, h in enumerate(self.headers): - if h.has_edits: - self.save_file(f) - - # callback for save file button - def save_file_button_cb(self, widget, f): - self.save_file(f) - - # callback for save file as button - def save_file_as_button_cb(self, widget, f): - self.save_file(f, True) - - # callback for go to line menu item - def go_to_line_cb(self, widget, data): - parent = self.get_toplevel() - dialog = NumericDialog( - parent, - _('Go To Line...'), - _('Line Number: '), - val=1, - lower=1, - upper=self.panes[self.current_pane].max_line_number + 1 - ) - okay = (dialog.run() == Gtk.ResponseType.ACCEPT) - i = dialog.get_value() - dialog.destroy() - if okay: - self.go_to_line(i) - - # callback to receive notification when the name of a file changes - def swapped_panes_cb(self, widget, f_dst, f_src): - f0, f1 = self.headers[f_dst], self.headers[f_src] - f0.has_edits, f1.has_edits = f1.has_edits, f0.has_edits - info0, info1 = f1.info, f0.info - self.setFileInfo(f_dst, info0) - self.setFileInfo(f_src, info1) - - # callback to receive notification when the name of a file changes - def num_edits_changed_cb(self, widget, f): - self.headers[f].setEdits(self.panes[f].num_edits > 0) - - # callback to record changes to the viewer's mode - def mode_changed_cb(self, widget): - self.updateStatus() - - # update the viewer's current status message - def updateStatus(self) -> None: - if self.mode == EditMode.LINE: - s = _( - 'Press the enter key or double click to edit. Press the space bar or use the ' - 'RMB menu to manually align.' - ) - elif self.mode == EditMode.CHAR: - s = _('Press the escape key to finish editing.') - elif self.mode == EditMode.ALIGN: - s = _( - 'Select target line and press the space bar to align. Press the escape key to ' - 'cancel.' - ) - else: - s = None - self.status = s - self.emit('status_changed', s) - - # gets the status bar text - def getStatus(self) -> Optional[str]: - return self.status - - # callback to display the cursor in a pane - def cursor_changed_cb(self, widget): - for f, footer in enumerate(self.footers): - footer.updateCursor(self, f) - - # callback to display the format of a pane - def format_changed_cb(self, widget, f, fmt): - self.footers[f].setFormat(fmt) - - def __init__(self, rc_dir, *args, **kwargs): - super().__init__(type=Gtk.WindowType.TOPLEVEL, *args, **kwargs) - - self.prefs = Preferences(os.path.join(rc_dir, 'prefs')) - # number of created viewers (used to label some tabs) - self.viewer_count = 0 - - # get monitor resolution - monitor_geometry = Gdk.Display.get_default().get_monitor(0).get_geometry() - - # state information that should persist across sessions - self.bool_state = { - 'window_maximized': False, - 'search_matchcase': False, - 'search_backwards': False - } - self.int_state = {'window_width': 1024, 'window_height': 768} - self.int_state['window_x'] = max( - 0, - (monitor_geometry.width - self.int_state['window_width']) / 2 - ) - self.int_state['window_y'] = max( - 0, - (monitor_geometry.height - self.int_state['window_height']) / 2 - ) - self.connect('configure-event', self.configure_cb) - self.connect('window-state-event', self.window_state_cb) - - # search history is application wide - self.search_pattern: Optional[str] = None - self.search_history: List[str] = [] - - self.connect('delete-event', self.delete_cb) - - # create a Box for our contents - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - - # create some custom icons for merging - DIFFUSE_STOCK_NEW_2WAY_MERGE = 'diffuse-new-2way-merge' - DIFFUSE_STOCK_NEW_3WAY_MERGE = 'diffuse-new-3way-merge' - DIFFUSE_STOCK_LEFT_RIGHT = 'diffuse-left-right' - DIFFUSE_STOCK_RIGHT_LEFT = 'diffuse-right-left' - - # get default theme and window scale factor - default_theme = Gtk.IconTheme.get_default() - scale_factor = self.get_scale_factor() - - icon_size = Gtk.IconSize.lookup(Gtk.IconSize.LARGE_TOOLBAR).height - factory = Gtk.IconFactory() - - # render the base item used to indicate a new document - p0 = default_theme.load_icon_for_scale('document-new', icon_size, scale_factor, 0) - w, h = p0.get_width(), p0.get_height() - - # render new 2-way merge icon - s = 0.8 - sw, sh = int(s * w), int(s * h) - w1, h1 = w - sw, h - sh - p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) - p.fill(0) - p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - p0.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - factory.add(DIFFUSE_STOCK_NEW_2WAY_MERGE, Gtk.IconSet.new_from_pixbuf(p)) - - # render new 3-way merge icon - s = 0.7 - sw, sh = int(s * w), int(s * h) - w1, h1 = (w - sw) / 2, (h - sh) / 2 - w2, h2 = w - sw, h - sh - p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) - p.fill(0) - p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - p0.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - p0.composite(p, w2, h2, sw, sh, w2, h2, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - factory.add(DIFFUSE_STOCK_NEW_3WAY_MERGE, Gtk.IconSet.new_from_pixbuf(p)) - - # render the left and right arrow we will use in our custom icons - p0 = default_theme.load_icon_for_scale('go-next', icon_size, scale_factor, 0) - p1 = default_theme.load_icon_for_scale('go-previous', icon_size, scale_factor, 0) - w, h, s = p0.get_width(), p0.get_height(), 0.65 - sw, sh = int(s * w), int(s * h) - w1, h1 = w - sw, h - sh - - # create merge from left then right icon - p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) - p.fill(0) - p1.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - factory.add(DIFFUSE_STOCK_LEFT_RIGHT, Gtk.IconSet.new_from_pixbuf(p)) - - # create merge from right then left icon - p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) - p.fill(0) - p0.composite(p, 0, h1, sw, sh, 0, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - p1.composite(p, w1, 0, sw, sh, w1, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) - factory.add(DIFFUSE_STOCK_RIGHT_LEFT, Gtk.IconSet.new_from_pixbuf(p)) - - # make the icons available for use - factory.add_default() - - menuspecs = [] - menuspecs.append([_('_File'), [ - [ - [_('_Open File...'), self.open_file_cb, None, 'open_file'], - [_('Open File In New _Tab...'), self.open_file_in_new_tab_cb, None, 'open_file_in_new_tab'], # noqa: E501 - [_('Open _Modified Files...'), self.open_modified_files_cb, None, 'open_modified_files'], # noqa: E501 - [_('Open Commi_t...'), self.open_commit_cb, None, 'open_commit'], - [_('_Reload File'), self.reload_file_cb, None, 'reload_file'], - ], [ - [_('_Save File'), self.save_file_cb, None, 'save_file'], - [_('Save File _As...'), self.save_file_as_cb, None, 'save_file_as'], - [_('Save A_ll'), self.save_all_cb, None, 'save_all'], - ], [ - [_('New _2-Way File Merge'), self.new_2_way_file_merge_cb, None, 'new_2_way_file_merge'], # noqa: E501 - [_('New _3-Way File Merge'), self.new_3_way_file_merge_cb, None, 'new_3_way_file_merge'], # noqa: E501 - [_('New _N-Way File Merge...'), self.new_n_way_file_merge_cb, None, 'new_n_way_file_merge'], # noqa: E501 - ], [ - [_('_Close Tab'), self.close_tab_cb, None, 'close_tab'], - [_('_Undo Close Tab'), self.undo_close_tab_cb, None, 'undo_close_tab'], - [_('_Quit'), self.quit_cb, None, 'quit'] - ] - ]]) - - menuspecs.append([_('_Edit'), [ - [ - [_('_Undo'), self.button_cb, 'undo', 'undo'], - [_('_Redo'), self.button_cb, 'redo', 'redo'], - ], [ - [_('Cu_t'), self.button_cb, 'cut', 'cut'], - [_('_Copy'), self.button_cb, 'copy', 'copy'], - [_('_Paste'), self.button_cb, 'paste', 'paste'], - ], [ - [_('Select _All'), self.button_cb, 'select_all', 'select_all'], - [_('C_lear Edits'), self.button_cb, 'clear_edits', 'clear_edits'], - [_('_Dismiss All Edits'), self.button_cb, 'dismiss_all_edits', 'dismiss_all_edits'], - ], [ - [_('_Find...'), self.find_cb, None, 'find'], - [_('Find _Next'), self.find_next_cb, None, 'find_next'], - [_('Find Pre_vious'), self.find_previous_cb, None, 'find_previous'], - [_('_Go To Line...'), self.go_to_line_cb, None, 'go_to_line'], - ], [ - [_('Pr_eferences...'), self.preferences_cb, None, 'preferences'] - ] - ]]) - - syntax_menu = [[[_('None'), None, '', 'syntax_highlighting']]] - names = theResources.getSyntaxNames() - variant = GLib.Variant.new_string('') - self.syntax_action = Gio.SimpleAction.new_stateful( - 'syntax_highlighting', variant.get_type(), variant - ) - self.syntax_action.connect('change-state', self.syntax_cb) - self.add_action(self.syntax_action) - if len(names) > 0: - syntax_section = [] - names.sort(key=str.lower) - for name in names: - syntax_section.append( - [ - name, - None, - name, - 'syntax_highlighting', - ] - ) - syntax_menu.append(syntax_section) - - menuspecs.append([_('_View'), [ - [ - [_('_Syntax Highlighting'), None, None, None, syntax_menu] - ], [ - [_('Re_align All'), self.button_cb, 'realign_all', 'realign_all'], - [_('_Isolate'), self.button_cb, 'isolate', 'isolate'], - ], [ - [_('_First Difference'), self.button_cb, 'first_difference', 'first_difference'], - [_('_Previous Difference'), self.button_cb, 'previous_difference', 'previous_difference'], # noqa: E501 - [_('_Next Difference'), self.button_cb, 'next_difference', 'next_difference'], - [_('_Last Difference'), self.button_cb, 'last_difference', 'last_difference'], - ], [ - [_('Fir_st Tab'), self.first_tab_cb, None, 'first_tab'], - [_('Pre_vious Tab'), self.previous_tab_cb, None, 'previous_tab'], - [_('Next _Tab'), self.next_tab_cb, None, 'next_tab'], - [_('Las_t Tab'), self.last_tab_cb, None, 'last_tab'], - ], [ - [_('Shift Pane _Right'), self.button_cb, 'shift_pane_right', 'shift_pane_right'], - [_('Shift Pane _Left'), self.button_cb, 'shift_pane_left', 'shift_pane_left'] - ] - ]]) - - menuspecs.append([_('F_ormat'), [ - [ - [_('Convert To _Upper Case'), self.button_cb, 'convert_to_upper_case', 'convert_to_upper_case'], # noqa: E501 - [_('Convert To _Lower Case'), self.button_cb, 'convert_to_lower_case', 'convert_to_lower_case'], # noqa: E501 - ], [ - [_('Sort Lines In _Ascending Order'), self.button_cb, 'sort_lines_in_ascending_order', 'sort_lines_in_ascending_order'], # noqa: E501 - [_('Sort Lines In D_escending Order'), self.button_cb, 'sort_lines_in_descending_order', 'sort_lines_in_descending_order'], # noqa: E501 - ], [ - [_('Remove Trailing _White Space'), self.button_cb, 'remove_trailing_white_space', 'remove_trailing_white_space'], # noqa: E501 - [_('Convert Tabs To _Spaces'), self.button_cb, 'convert_tabs_to_spaces', 'convert_tabs_to_spaces'], # noqa: E501 - [_('Convert Leading Spaces To _Tabs'), self.button_cb, 'convert_leading_spaces_to_tabs', 'convert_leading_spaces_to_tabs'], # noqa: E501 - ], [ - [_('_Increase Indenting'), self.button_cb, 'increase_indenting', 'increase_indenting'], # noqa: E501 - [_('De_crease Indenting'), self.button_cb, 'decrease_indenting', 'decrease_indenting'], # noqa: E501 - ], [ - [_('Convert To _DOS Format'), self.button_cb, 'convert_to_dos', 'convert_to_dos'], - [_('Convert To _Mac Format'), self.button_cb, 'convert_to_mac', 'convert_to_mac'], - [_('Convert To Uni_x Format'), self.button_cb, 'convert_to_unix', 'convert_to_unix'] - ] - ]]) - - menuspecs.append([_('_Merge'), [ - [ - [_('Copy Selection _Right'), self.button_cb, 'copy_selection_right', 'copy_selection_right'], # noqa: E501 - [_('Copy Selection _Left'), self.button_cb, 'copy_selection_left', 'copy_selection_left'], # noqa: E501 - ], [ - [_('Copy Left _Into Selection'), self.button_cb, 'copy_left_into_selection', 'copy_left_into_selection'], # noqa: E501 - [_('Copy Right I_nto Selection'), self.button_cb, 'copy_right_into_selection', 'copy_right_into_selection'], # noqa: E501 - [_('_Merge From Left Then Right'), self.button_cb, 'merge_from_left_then_right', 'merge_from_left_then_right'], # noqa: E501 - [_('M_erge From Right Then Left'), self.button_cb, 'merge_from_right_then_left', 'merge_from_right_then_left'] # noqa: E501 - ] - ]]) - - menuspecs.append([_('_Help'), [ - [ - [_('_Help Contents...'), self.help_contents_cb, None, 'help_contents'], - ], [ - [_('_About %s...') % (constants.APP_NAME, ), self.about_cb, None, 'about'] - ] - ]]) - - # used to disable menu events when switching tabs - self.menu_update_depth = 0 - - menubar = Gio.Menu() - for label, sections in menuspecs: - menubar.append_submenu(label, self._create_menu(sections)) - self.get_application().set_menubar(menubar) - - # create button bar - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - _append_buttons(hbox, Gtk.IconSize.LARGE_TOOLBAR, [ - [DIFFUSE_STOCK_NEW_2WAY_MERGE, self.new_2_way_file_merge_cb, None, _('New 2-Way File Merge')], # noqa: E501 - [DIFFUSE_STOCK_NEW_3WAY_MERGE, self.new_3_way_file_merge_cb, None, _('New 3-Way File Merge')], # noqa: E501 - [], - [Gtk.STOCK_EXECUTE, self.button_cb, 'realign_all', _('Realign All')], - [Gtk.STOCK_GOTO_TOP, self.button_cb, 'first_difference', _('First Difference')], - [Gtk.STOCK_GO_UP, self.button_cb, 'previous_difference', _('Previous Difference')], - [Gtk.STOCK_GO_DOWN, self.button_cb, 'next_difference', _('Next Difference')], - [Gtk.STOCK_GOTO_BOTTOM, self.button_cb, 'last_difference', _('Last Difference')], - [], - [Gtk.STOCK_GOTO_LAST, self.button_cb, 'copy_selection_right', _('Copy Selection Right')], # noqa: E501 - [Gtk.STOCK_GOTO_FIRST, self.button_cb, 'copy_selection_left', _('Copy Selection Left')], - [Gtk.STOCK_GO_FORWARD, self.button_cb, 'copy_left_into_selection', _('Copy Left Into Selection')], # noqa: E501 - [Gtk.STOCK_GO_BACK, self.button_cb, 'copy_right_into_selection', _('Copy Right Into Selection')], # noqa: E501 - [DIFFUSE_STOCK_LEFT_RIGHT, self.button_cb, 'merge_from_left_then_right', _('Merge From Left Then Right')], # noqa: E501 - [DIFFUSE_STOCK_RIGHT_LEFT, self.button_cb, 'merge_from_right_then_left', _('Merge From Right Then Left')], # noqa: E501 - [], - [Gtk.STOCK_UNDO, self.button_cb, 'undo', _('Undo')], - [Gtk.STOCK_REDO, self.button_cb, 'redo', _('Redo')], - [Gtk.STOCK_CUT, self.button_cb, 'cut', _('Cut')], - [Gtk.STOCK_COPY, self.button_cb, 'copy', _('Copy')], - [Gtk.STOCK_PASTE, self.button_cb, 'paste', _('Paste')], - [Gtk.STOCK_CLEAR, self.button_cb, 'clear_edits', _('Clear Edits')] - ]) - # avoid the button bar from dictating the minimum window size - hbox.set_size_request(0, hbox.get_size_request()[1]) - vbox.pack_start(hbox, False, False, 0) - hbox.show() - - self.closed_tabs = [] - self.notebook = notebook = Gtk.Notebook() - notebook.set_scrollable(True) - notebook.connect('switch-page', self.switch_page_cb) - vbox.pack_start(notebook, True, True, 0) - notebook.show() - - # Add a status bar to the bottom - self.statusbar = statusbar = Gtk.Statusbar() - vbox.pack_start(statusbar, False, False, 0) - statusbar.show() - - self.add(vbox) - vbox.show() - self.connect('focus-in-event', self.focus_in_cb) - - def _create_menu(self, sections): - menu = Gio.Menu.new() - for section in sections: - section_menu = Gio.Menu.new() - for label, cb, cb_data, accel, *submenu in section: - if submenu: - (submenu,) = submenu - section_menu.append_submenu(label, self._create_menu(submenu)) - else: - if cb_data is not None: - cb_data = GLib.Variant.new_string(cb_data) - accel = accel.replace('_', '-') - if cb is not None: - action = Gio.SimpleAction.new(accel, cb_data and cb_data.get_type()) - action.connect('activate', cb) - self.add_action(action) - item = Gio.MenuItem.new(label) - item.set_action_and_target_value('win.' + accel, cb_data) - key_binding = theResources.getKeyBindings('menu', accel) - if len(key_binding) > 0: - key, modifier = key_binding[0] - self.get_application().set_accels_for_action( - Gio.Action.print_detailed_name('win.' + accel, cb_data), - [Gtk.accelerator_name(key, modifier)], - ) - section_menu.append_item(item) - menu.append_section(None, section_menu) - return menu - - # notifies all viewers on focus changes so they may check for external - # changes to files - def focus_in_cb(self, widget, event): - changed = [] - for i in range(self.notebook.get_n_pages()): - page = self.notebook.get_nth_page(i) - for f, h in enumerate(page.headers): - if h.has_file_changed_on_disk(): - changed.append((page, f)) - - if changed: - filenames = [] - for (page, f) in changed: - h = page.headers[f] - filename = h.info.label if h.info.label is not None else h.info.name - filenames.append(filename) - - primary_text = _('Changes detected') - secondary_text = '' - if len(filenames) == 1: - secondary_text = _( - 'The file "%s" changed on disk.\n\n' - 'Do you want to reload the file?' - ) % (filenames[0],) - else: - secondary_text = _( - 'The following files changed on disk:\n%s\n\n' - 'Do you want to reload these files?' - ) % ('\n'.join('- ' + filename for filename in filenames),) - - dialog = Gtk.MessageDialog( - transient_for=self.get_toplevel(), - message_type=Gtk.MessageType.QUESTION, - buttons=Gtk.ButtonsType.YES_NO, - text=primary_text) - dialog.format_secondary_text(secondary_text) - dialog.set_default_response(Gtk.ResponseType.YES) - - button = dialog.get_widget_for_response(Gtk.ResponseType.YES) - button.get_style_context().add_class(Gtk.STYLE_CLASS_SUGGESTED_ACTION) - - response = dialog.run() - dialog.destroy() - - if response == Gtk.ResponseType.YES: - for page, f in changed: - page.open_file(f, True) - - # record the window's position and size - def configure_cb(self, widget, event): - # read the state directly instead of using window_maximized as the order - # of configure/window_state events is undefined - if (widget.get_window().get_state() & Gdk.WindowState.MAXIMIZED) == 0: - self.int_state['window_x'], self.int_state['window_y'] = widget.get_window().get_root_origin() # noqa: E501 - self.int_state['window_width'] = event.width - self.int_state['window_height'] = event.height - - # record the window's maximized state - def window_state_cb(self, window, event): - self.bool_state['window_maximized'] = ( - (event.new_window_state & Gdk.WindowState.MAXIMIZED) != 0 - ) - - # load state information that should persist across sessions - def loadState(self, statepath: str) -> None: - if os.path.isfile(statepath): - try: - f = open(statepath, 'r') - ss = utils.readlines(f) - f.close() - for j, s in enumerate(ss): - try: - a = shlex.split(s, True) - if len(a) > 0: - if len(a) == 2 and a[0] in self.bool_state: - self.bool_state[a[0]] = (a[1] == 'True') - elif len(a) == 2 and a[0] in self.int_state: - self.int_state[a[0]] = int(a[1]) - else: - raise ValueError() - except ValueError: - # this may happen if the state was written by a - # different version -- don't bother the user - utils.logDebug(f'Error processing line {j + 1} of {statepath}.') - except IOError: - # bad $HOME value? -- don't bother the user - utils.logDebug(f'Error reading {statepath}.') - - self.move(self.int_state['window_x'], self.int_state['window_y']) - self.resize(self.int_state['window_width'], self.int_state['window_height']) - if self.bool_state['window_maximized']: - self.maximize() - - # save state information that should persist across sessions - def saveState(self, statepath: str) -> None: - try: - ss = [] - for k, v in self.bool_state.items(): - ss.append(f'{k} {v}\n') - for k, v in self.int_state.items(): - ss.append(f'{k} {v}\n') - ss.sort() - f = open(statepath, 'w') - f.write(f'# This state file was generated by {constants.APP_NAME} {constants.VERSION}.\n\n') # noqa: E501 - for s in ss: - f.write(s) - f.close() - except IOError: - # bad $HOME value? -- don't bother the user - utils.logDebug(f'Error writing {statepath}.') - - # select viewer for a newly selected file in the confirm close dialogue - def _confirmClose_row_activated_cb(self, tree, path, col, model): - self.notebook.set_current_page(self.notebook.page_num(model[path][3])) - - # toggle save state for a file listed in the confirm close dialogue - def _confirmClose_toggle_cb(self, cell, path, model): - model[path][0] = not model[path][0] - - # returns True if the list of viewers can be closed. The user will be - # given a chance to save any modified files before this method completes. - def confirmCloseViewers(self, viewers: List[FileDiffViewer]) -> bool: - # make a list of modified files - model = Gtk.ListStore.new([ - GObject.TYPE_BOOLEAN, - GObject.TYPE_STRING, - GObject.TYPE_INT, - GObject.TYPE_OBJECT]) - for v in viewers: - for f, h in enumerate(v.headers): - if h.has_edits: - model.append((True, v.title, f + 1, v)) - if len(model) == 0: - # there are no modified files, the viewers can be closed - return True - - # ask the user which files should be saved - dialog = Gtk.MessageDialog(transient_for=self.get_toplevel(), - destroy_with_parent=True, - message_type=Gtk.MessageType.WARNING, - buttons=Gtk.ButtonsType.NONE, - text=_('Some files have unsaved changes. ' - 'Select the files to save before closing.')) - dialog.set_resizable(True) - dialog.set_title(constants.APP_NAME) - # add list of files with unsaved changes - sw = Gtk.ScrolledWindow() - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - treeview = Gtk.TreeView.new_with_model(model) - r = Gtk.CellRendererToggle() - r.connect('toggled', self._confirmClose_toggle_cb, model) - column = Gtk.TreeViewColumn(None, r) - column.add_attribute(r, 'active', 0) - treeview.append_column(column) - r = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(_('Tab'), r, text=1) - column.set_resizable(True) - column.set_expand(True) - column.set_sort_column_id(1) - treeview.append_column(column) - column = Gtk.TreeViewColumn(_('Pane'), r, text=2) - column.set_resizable(True) - column.set_sort_column_id(2) - treeview.append_column(column) - treeview.connect('row-activated', self._confirmClose_row_activated_cb, model) - sw.add(treeview) - treeview.show() - dialog.vbox.pack_start(sw, True, True, 0) - sw.show() - # add custom set of action buttons - dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) - button = Gtk.Button.new_with_mnemonic(_('Close _Without Saving')) - dialog.add_action_widget(button, Gtk.ResponseType.REJECT) - button.show() - dialog.add_button(Gtk.STOCK_SAVE, Gtk.ResponseType.OK) - dialog.set_default_response(Gtk.ResponseType.CANCEL) - response = dialog.run() - dialog.destroy() - if response == Gtk.ResponseType.OK: - # save all checked files - it = model.get_iter_first() - while it: - if model.get_value(it, 0): - f = model.get_value(it, 2) - 1 - v = model.get_value(it, 3) - if not v.save_file(f): - # cancel if we failed to save a file - return False - it = model.iter_next(it) - return True - # cancel if the user did not choose 'Close Without Saving' or 'Save' - return response == Gtk.ResponseType.REJECT - - # callback for the close button on each tab - def remove_tab_cb(self, widget, data): - nb = self.notebook - if nb.get_n_pages() > 1: - # warn about losing unsaved changes before removing a tab - if self.confirmCloseViewers([data]): - self.closed_tabs.append((nb.page_num(data), data, nb.get_tab_label(data))) - nb.remove(data) - nb.set_show_tabs(self.prefs.getBool('tabs_always_show') or nb.get_n_pages() > 1) - elif not self.prefs.getBool('tabs_warn_before_quit') or self._confirm_tab_close(): - self.quit_cb(widget, data) - - # convenience method to request confirmation when closing the last tab - def _confirm_tab_close(self) -> bool: - dialog = utils.MessageDialog( - self.get_toplevel(), - Gtk.MessageType.WARNING, - _('Closing this tab will quit %s.') % (constants.APP_NAME, ) - ) - end = (dialog.run() == Gtk.ResponseType.OK) - dialog.destroy() - return end - - # callback for RMB menu on notebook tabs - def notebooktab_pick_cb(self, widget, data): - self.notebook.set_current_page(data) - - # callback used when a mouse button is pressed on a notebook tab - def notebooktab_button_press_cb(self, widget, event, data): - if event.button == 2: - # remove the tab on MMB - self.remove_tab_cb(widget, data) - elif event.button == 3: - # create a popup to pick a tab for focus on RMB - menu = Gtk.Menu() - nb = self.notebook - for i in range(nb.get_n_pages()): - viewer = nb.get_nth_page(i) - item = Gtk.MenuItem.new_with_label(nb.get_tab_label(viewer).get_text()) - item.connect('activate', self.notebooktab_pick_cb, i) - menu.append(item) - item.show() - if viewer is data: - menu.select_item(item) - menu.popup(None, None, None, event.button, event.time) - - # update window's title - def updateTitle(self, viewer: FileDiffViewer) -> None: - title = self.notebook.get_tab_label(viewer).get_text() - self.set_title(f'{title} - {constants.APP_NAME}') - - # update the message in the status bar - def setStatus(self, s: Optional[str]) -> None: - sb = self.statusbar - context = sb.get_context_id('Message') - sb.pop(context) - if s is None: - s = '' - sb.push(context, s) - - # update the label in the status bar - def setSyntax(self, s): - # update menu - self.syntax_action.set_state(GLib.Variant.new_string(s or '')) - - # callback used when switching notebook pages - def switch_page_cb(self, widget, ptr, page_num): - viewer = widget.get_nth_page(page_num) - self.updateTitle(viewer) - self.setStatus(viewer.getStatus()) - self.setSyntax(viewer.getSyntax()) - - # callback used when a viewer's title changes - def title_changed_cb(self, widget, title): - # update the label in the notebook's tab - self.notebook.get_tab_label(widget).set_text(title) - if widget is self.getCurrentViewer(): - self.updateTitle(widget) - - # callback used when a viewer's status changes - def status_changed_cb(self, widget, s): - # update the label in the notebook's tab - if widget is self.getCurrentViewer(): - self.setStatus(s) - - # callback used when a viewer's syntax changes - def syntax_changed_cb(self, widget, s): - # update the label - if widget is self.getCurrentViewer(): - self.setSyntax(s) - - # create an empty viewer with 'n' panes - def newFileDiffViewer(self, n: int) -> FileDiffViewer: - self.viewer_count += 1 - tabname = _('File Merge %d') % (self.viewer_count, ) - tab = NotebookTab(tabname, Gtk.STOCK_FILE) - viewer = Diffuse.FileDiffViewer(n, self.prefs, tabname) - tab.button.connect('clicked', self.remove_tab_cb, viewer) - tab.connect('button-press-event', self.notebooktab_button_press_cb, viewer) - self.notebook.append_page(viewer, tab) - if hasattr(self.notebook, 'set_tab_reorderable'): - # some PyGTK packages incorrectly omit this method - self.notebook.set_tab_reorderable(viewer, True) - tab.show() - viewer.show() - self.notebook.set_show_tabs( - self.prefs.getBool('tabs_always_show') or self.notebook.get_n_pages() > 1 - ) - viewer.connect('title-changed', self.title_changed_cb) - viewer.connect('status-changed', self.status_changed_cb) - viewer.connect('syntax-changed', self.syntax_changed_cb) - return viewer - - # create a new viewer to display 'items' - def newLoadedFileDiffViewer(self, items): - specs = [] - if len(items) == 0: - for i in range(self.prefs.getInt('tabs_default_panes')): - specs.append(FileInfo()) - elif len(items) == 1 and len(items[0][1]) == 1: - # one file specified - # determine which other files to compare it with - name, data, label = items[0] - rev, encoding = data[0] - vcs = theVCSs.findByFilename(name, self.prefs) - if vcs is None: - # shift the existing file so it will be in the second pane - specs.append(FileInfo()) - specs.append(FileInfo(name, encoding, None, None, label)) - else: - if rev is None: - # no revision specified assume defaults - for name, rev in vcs.getFileTemplate(self.prefs, name): - if rev is None: - s = label - else: - s = None - specs.append(FileInfo(name, encoding, vcs, rev, s)) - else: - # single revision specified - specs.append(FileInfo(name, encoding, vcs, rev)) - specs.append(FileInfo(name, encoding, None, None, label)) - else: - # multiple files specified, use one pane for each file - for name, data, label in items: - for rev, encoding in data: - if rev is None: - vcs, s = None, label - else: - vcs, s = theVCSs.findByFilename(name, self.prefs), None - specs.append(FileInfo(name, encoding, vcs, rev, s)) - - # open a new viewer - viewer = self.newFileDiffViewer(max(2, len(specs))) - - # load the files - for i, spec in enumerate(specs): - viewer.load(i, spec) - return viewer - - # create a new viewer for 'items' - def createSingleTab(self, items, labels, options): - if len(items) > 0: - self.newLoadedFileDiffViewer(_assign_file_labels(items, labels)).setOptions(options) - - # create a new viewer for each item in 'items' - def createSeparateTabs(self, items, labels, options): - # all tabs inherit the first tab's revision and encoding specifications - items = [(name, items[0][1]) for name, data in items] - for item in _assign_file_labels(items, labels): - self.newLoadedFileDiffViewer([item]).setOptions(options) - - # create a new viewer for each modified file found in 'items' - def createCommitFileTabs(self, items, labels, options): - new_items = [] - for item in items: - name, data = item - # get full path to an existing ancestor directory - dn = os.path.abspath(name) - while not os.path.isdir(dn): - dn, old_dn = os.path.dirname(dn), dn - if dn == old_dn: - break - if len(new_items) == 0 or dn != new_items[-1][0]: - new_items.append([dn, None, []]) - dst = new_items[-1] - dst[1] = data[-1][1] - dst[2].append(name) - for dn, encoding, names in new_items: - vcs = theVCSs.findByFolder(dn, self.prefs) - if vcs is not None: - try: - for specs in vcs.getCommitTemplate(self.prefs, options['commit'], names): - viewer = self.newFileDiffViewer(len(specs)) - for i, spec in enumerate(specs): - name, rev = spec - viewer.load(i, FileInfo(name, encoding, vcs, rev)) - viewer.setOptions(options) - except (IOError, OSError): - utils.logErrorAndDialog( - _('Error retrieving commits for %s.') % (dn, ), - self.get_toplevel() - ) - - # create a new viewer for each modified file found in 'items' - def createModifiedFileTabs(self, items, labels, options): - new_items = [] - for item in items: - name, data = item - # get full path to an existing ancestor directory - dn = os.path.abspath(name) - while not os.path.isdir(dn): - dn, old_dn = os.path.dirname(dn), dn - if dn == old_dn: - break - if len(new_items) == 0 or dn != new_items[-1][0]: - new_items.append([dn, None, []]) - dst = new_items[-1] - dst[1] = data[-1][1] - dst[2].append(name) - for dn, encoding, names in new_items: - vcs = theVCSs.findByFolder(dn, self.prefs) - if vcs is not None: - try: - for specs in vcs.getFolderTemplate(self.prefs, names): - viewer = self.newFileDiffViewer(len(specs)) - for i, spec in enumerate(specs): - name, rev = spec - viewer.load(i, FileInfo(name, encoding, vcs, rev)) - viewer.setOptions(options) - except (IOError, OSError): - utils.logErrorAndDialog( - _('Error retrieving modifications for %s.') % (dn, ), - self.get_toplevel() - ) - - # close all tabs without differences - def closeOnSame(self) -> None: - for i in range(self.notebook.get_n_pages() - 1, -1, -1): - if not self.notebook.get_nth_page(i).hasDifferences(): - self.notebook.remove_page(i) - - # returns True if the application can safely quit - def confirmQuit(self) -> bool: - nb = self.notebook - return self.confirmCloseViewers([nb.get_nth_page(i) for i in range(nb.get_n_pages())]) - - # respond to close window request from the window manager - def delete_cb(self, widget, event): - if self.confirmQuit(): - self.get_application().quit() - return False - return True - - # returns the currently focused viewer - def getCurrentViewer(self) -> FileDiffViewer: - return self.notebook.get_nth_page(self.notebook.get_current_page()) - - # callback for the open file menu item - def open_file_cb(self, widget, data): - self.getCurrentViewer().open_file_cb(widget, data) - - # callback for the open file menu item - def open_file_in_new_tab_cb(self, widget, data): - dialog = FileChooserDialog( - _('Open File In New Tab'), - self.get_toplevel(), - self.prefs, - Gtk.FileChooserAction.OPEN, - Gtk.STOCK_OPEN, - True - ) - dialog.set_default_response(Gtk.ResponseType.OK) - accept = (dialog.run() == Gtk.ResponseType.OK) - name, encoding = dialog.get_filename(), dialog.get_encoding() - rev = dialog.get_revision().strip() - if rev == '': - rev = None - dialog.destroy() - if accept: - viewer = self.newLoadedFileDiffViewer([(name, [(rev, encoding)], None)]) - self.notebook.set_current_page(self.notebook.get_n_pages() - 1) - viewer.grab_focus() - - # callback for the open modified files menu item - def open_modified_files_cb(self, widget, data): - parent = self.get_toplevel() - dialog = FileChooserDialog( - _('Choose Folder With Modified Files'), - parent, - self.prefs, - Gtk.FileChooserAction.SELECT_FOLDER, - Gtk.STOCK_OPEN - ) - dialog.set_default_response(Gtk.ResponseType.OK) - accept = (dialog.run() == Gtk.ResponseType.OK) - name, encoding = dialog.get_filename(), dialog.get_encoding() - dialog.destroy() - if accept: - n = self.notebook.get_n_pages() - self.createModifiedFileTabs([(name, [(None, encoding)])], [], {}) - if self.notebook.get_n_pages() > n: - # we added some new tabs, focus on the first one - self.notebook.set_current_page(n) - self.getCurrentViewer().grab_focus() - else: - utils.logErrorAndDialog(_('No modified files found.'), parent) - - # callback for the open commit menu item - def open_commit_cb(self, widget, data): - parent = self.get_toplevel() - dialog = FileChooserDialog( - _('Choose Folder With Commit'), - parent, self.prefs, - Gtk.FileChooserAction.SELECT_FOLDER, - Gtk.STOCK_OPEN, - True - ) - dialog.set_default_response(Gtk.ResponseType.OK) - accept = (dialog.run() == Gtk.ResponseType.OK) - name = dialog.get_filename() - rev = dialog.get_revision() - encoding = dialog.get_encoding() - dialog.destroy() - if accept: - n = self.notebook.get_n_pages() - self.createCommitFileTabs([(name, [(None, encoding)])], [], {'commit': rev}) - if self.notebook.get_n_pages() > n: - # we added some new tabs, focus on the first one - self.notebook.set_current_page(n) - self.getCurrentViewer().grab_focus() - else: - utils.logErrorAndDialog(_('No committed files found.'), parent) - - # callback for the reload file menu item - def reload_file_cb(self, widget, data): - self.getCurrentViewer().reload_file_cb(widget, data) - - # callback for the save file menu item - def save_file_cb(self, widget, data): - self.getCurrentViewer().save_file_cb(widget, data) - - # callback for the save file as menu item - def save_file_as_cb(self, widget, data): - self.getCurrentViewer().save_file_as_cb(widget, data) - - # callback for the save all menu item - def save_all_cb(self, widget, data): - for i in range(self.notebook.get_n_pages()): - self.notebook.get_nth_page(i).save_all_cb(widget, data) - - # callback for the new 2-way file merge menu item - def new_2_way_file_merge_cb(self, widget, data): - viewer = self.newFileDiffViewer(2) - self.notebook.set_current_page(self.notebook.get_n_pages() - 1) - viewer.grab_focus() - - # callback for the new 3-way file merge menu item - def new_3_way_file_merge_cb(self, widget, data): - viewer = self.newFileDiffViewer(3) - self.notebook.set_current_page(self.notebook.get_n_pages() - 1) - viewer.grab_focus() - - # callback for the new n-way file merge menu item - def new_n_way_file_merge_cb(self, widget, data): - parent = self.get_toplevel() - dialog = NumericDialog( - parent, - _('New N-Way File Merge...'), - _('Number of panes: '), - val=4, - lower=2, - upper=16 - ) - okay = (dialog.run() == Gtk.ResponseType.ACCEPT) - npanes = dialog.get_value() - dialog.destroy() - if okay: - viewer = self.newFileDiffViewer(npanes) - self.notebook.set_current_page(self.notebook.get_n_pages() - 1) - viewer.grab_focus() - - # callback for the close tab menu item - def close_tab_cb(self, widget, data): - self.remove_tab_cb(widget, self.notebook.get_nth_page(self.notebook.get_current_page())) - - # callback for the undo close tab menu item - def undo_close_tab_cb(self, widget, data): - if len(self.closed_tabs) > 0: - i, tab, tab_label = self.closed_tabs.pop() - self.notebook.insert_page(tab, tab_label, i) - self.notebook.set_current_page(i) - self.notebook.set_show_tabs(True) - - # callback for the quit menu item - def quit_cb(self, widget, data): - if self.confirmQuit(): - self.get_application().quit() - - # request search parameters if force=True and then perform a search in the - # current viewer pane - def find(self, force: bool, reverse: bool) -> None: - viewer = self.getCurrentViewer() - if force or self.search_pattern is None: - # construct search dialog - history = self.search_history - pattern = viewer.getSelectedText() - for c in '\r\n': - i = pattern.find(c) - if i >= 0: - pattern = pattern[:i] - dialog = SearchDialog(self.get_toplevel(), pattern, history) - dialog.match_case_button.set_active(self.bool_state['search_matchcase']) - dialog.backwards_button.set_active(self.bool_state['search_backwards']) - keep = (dialog.run() == Gtk.ResponseType.ACCEPT) - # persist the search options - pattern = dialog.get_search_text() - match_case = dialog.match_case_button.get_active() - backwards = dialog.backwards_button.get_active() - dialog.destroy() - if not keep or pattern == '': - return - # perform the search - self.search_pattern = pattern - if pattern in history: - del history[history.index(pattern)] - history.insert(0, pattern) - self.bool_state['search_matchcase'] = match_case - self.bool_state['search_backwards'] = backwards - - # determine where to start searching from - reverse ^= self.bool_state['search_backwards'] - from_start, more = False, True - while more: - if viewer.find( - self.search_pattern, - self.bool_state['search_matchcase'], - reverse, - from_start - ): - break - - if reverse: - msg = _('Phrase not found. Continue from the end of the file?') - else: - msg = _('Phrase not found. Continue from the start of the file?') - dialog = utils.MessageDialog(self.get_toplevel(), Gtk.MessageType.QUESTION, msg) - dialog.set_default_response(Gtk.ResponseType.OK) - more = (dialog.run() == Gtk.ResponseType.OK) - dialog.destroy() - from_start = True - - # callback for the find menu item - def find_cb(self, widget, data): - self.find(True, False) - - # callback for the find next menu item - def find_next_cb(self, widget, data): - self.find(False, False) - - # callback for the find previous menu item - def find_previous_cb(self, widget, data): - self.find(False, True) - - # callback for the go to line menu item - def go_to_line_cb(self, widget, data): - self.getCurrentViewer().go_to_line_cb(widget, data) - - # notify all viewers of changes to the preferences - def preferences_updated(self) -> None: - n = self.notebook.get_n_pages() - self.notebook.set_show_tabs(self.prefs.getBool('tabs_always_show') or n > 1) - for i in range(n): - self.notebook.get_nth_page(i).prefsUpdated() - - # callback for the preferences menu item - def preferences_cb(self, widget, data): - if self.prefs.runDialog(self.get_toplevel()): - self.preferences_updated() - - # callback for all of the syntax highlighting menu items - def syntax_cb(self, widget, data): - # ignore events while we update the menu when switching tabs - # also ignore notification of the newly disabled item - if self.menu_update_depth == 0 and widget.get_active(): - self.getCurrentViewer().setSyntax(data) - - # callback for the first tab menu item - def first_tab_cb(self, widget, data): - self.notebook.set_current_page(0) - - # callback for the previous tab menu item - def previous_tab_cb(self, widget, data): - i, n = self.notebook.get_current_page(), self.notebook.get_n_pages() - self.notebook.set_current_page((n + i - 1) % n) - - # callback for the next tab menu item - def next_tab_cb(self, widget, data): - i, n = self.notebook.get_current_page(), self.notebook.get_n_pages() - self.notebook.set_current_page((i + 1) % n) - - # callback for the last tab menu item - def last_tab_cb(self, widget, data): - self.notebook.set_current_page(self.notebook.get_n_pages() - 1) - - # callback for most menu items and buttons - def button_cb(self, widget, data): - self.getCurrentViewer().button_cb(widget, data.get_string()) - - # display help documentation - def help_contents_cb(self, widget, data): - help_url = None - if utils.isWindows(): - # help documentation is distributed as local HTML files - # search for localized manual first - parts = ['manual'] - if utils.lang is not None: - parts = ['manual'] - parts.extend(utils.lang.split('_')) - while len(parts) > 0: - help_file = os.path.join(utils.bin_dir, '_'.join(parts) + '.html') - if os.path.isfile(help_file): - # we found a help file - help_url = _path2url(help_file) - break - del parts[-1] - else: - # verify gnome-help is available - browser = None - p = os.environ.get('PATH', None) - if p is not None: - for s in p.split(os.pathsep): - fp = os.path.join(s, 'gnome-help') - if os.path.isfile(fp): - browser = fp - break - if browser is not None: - # find localized help file - if utils.lang is None: - parts = [] - else: - parts = utils.lang.split('_') - s = os.path.abspath(os.path.join(utils.bin_dir, '../share/gnome/help/diffuse')) - while True: - if len(parts) > 0: - d = '_'.join(parts) - else: - # fall back to using 'C' - d = 'C' - help_file = os.path.join(os.path.join(s, d), 'diffuse.xml') - if os.path.isfile(help_file): - args = [browser, _path2url(help_file, 'ghelp')] - # spawnvp is not available on some systems, use spawnv instead - os.spawnv(os.P_NOWAIT, args[0], args) - return - if len(parts) == 0: - break - del parts[-1] - if help_url is None: - # no local help file is available, show on-line help - help_url = constants.WEBSITE + 'manual.html' - # ask for localized manual - if utils.lang is not None: - help_url += '?lang=' + utils.lang - # use a web browser to display the help documentation - webbrowser.open(help_url) - - # callback for the about menu item - def about_cb(self, widget, data): - dialog = AboutDialog() - dialog.run() - dialog.destroy() - - -# convenience method for packing buttons into a container according to a -# template -def _append_buttons(box, size, specs): - for spec in specs: - if len(spec) > 0: - button = Gtk.Button() - button.set_relief(Gtk.ReliefStyle.NONE) - button.set_can_focus(False) - image = Gtk.Image() - image.set_from_stock(spec[0], size) - button.add(image) - image.show() - if len(spec) > 2: - button.connect('clicked', spec[1], spec[2]) - if len(spec) > 3: - button.set_tooltip_text(spec[3]) - box.pack_start(button, False, False, 0) - button.show() - else: - separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) - box.pack_start(separator, False, False, 5) - separator.show() - - -# constructs a full URL for the named file -def _path2url(path: str, proto: str = 'file') -> str: - r = [proto, ':///'] - s = os.path.abspath(path) - i = 0 - while i < len(s) and s[i] == os.sep: - i += 1 - for c in s[i:]: - if c == os.sep: - c = '/' - elif c == ':' and utils.isWindows(): - c = '|' - else: - v = ord(c) - if v <= 0x20 or v >= 0x7b or c in '$&+,/:;=?@"<>#%\\^[]`': - c = '%%%02X' % (v, ) - r.append(c) - return ''.join(r) - - -# assign user specified labels to the corresponding files -def _assign_file_labels(items, labels): - new_items = [] - ss = labels[::-1] - for name, data in items: - if ss: - s = ss.pop() - else: - s = None - new_items.append((name, data, s)) - return new_items - - -GObject.signal_new('title-changed', Diffuse.FileDiffViewer, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, (str, )) # noqa: E501 -GObject.signal_new('status-changed', Diffuse.FileDiffViewer, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, (str, )) # noqa: E501 -GObject.signal_new('title-changed', Diffuse.FileDiffViewer.PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) # noqa: E501 -GObject.signal_new('open', Diffuse.FileDiffViewer.PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) # noqa: E501 -GObject.signal_new('reload', Diffuse.FileDiffViewer.PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) # noqa: E501 -GObject.signal_new('save', Diffuse.FileDiffViewer.PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) # noqa: E501 -GObject.signal_new('save-as', Diffuse.FileDiffViewer.PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) # noqa: E501 - - -class Application(Gtk.Application): - def __init__(self, sysconfigdir, *args, **kwargs): + def __init__(self, sysconfigdir): super().__init__( - *args, application_id='io.github.mightycreak.Diffuse', - flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE | Gio.ApplicationFlags.NON_UNIQUE, - **kwargs, - ) + flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE | Gio.ApplicationFlags.NON_UNIQUE) + self.window = None self.sysconfigdir = sysconfigdir self.add_main_option( @@ -1971,10 +192,12 @@ ability to manually adjust line matching and directly edit files. Diffuse can also retrieve revisions of files from several VCSs for comparison and merging.''' )) - def do_activate(self): + def do_activate(self) -> None: + """Called when the application is activated.""" self.window.present() def do_command_line(self, command_line): + """Called to treat the command line options.""" options = command_line.get_options_dict() # convert GVariantDict -> GVariant -> dict options = options.end().unpack() @@ -1990,6 +213,7 @@ also retrieve revisions of files from several VCSs for comparison and merging.'' rc_dir = os.path.expanduser('~') subdirs.insert(0, '.config') rc_dir = utils.make_subdirs(rc_dir, subdirs) + # find the local data directory and create it if it didn't exist data_dir = os.environ.get('XDG_DATA_HOME', None) subdirs = ['diffuse'] @@ -1997,6 +221,7 @@ also retrieve revisions of files from several VCSs for comparison and merging.'' data_dir = os.path.expanduser('~') subdirs[:0] = ['.local', 'share'] data_dir = utils.make_subdirs(data_dir, subdirs) + # load resource files rc_files = [] if 'no-rcfile' not in options: @@ -2018,10 +243,14 @@ also retrieve revisions of files from several VCSs for comparison and merging.'' theResources.parse(rc_file) except IOError: utils.logError(_('Error reading %s.') % (rc_file,)) - self.window = diff = Diffuse(rc_dir, application=self) + + diff_window = DiffuseWindow(rc_dir, application=self) + self.window = diff_window + # load state self.statepath = os.path.join(data_dir, 'state') - self.window.loadState(self.statepath) + diff_window.loadState(self.statepath) + # process remaining command line arguments encoding = None revs = [] @@ -2030,10 +259,10 @@ also retrieve revisions of files from several VCSs for comparison and merging.'' had_specs = False labels = [] funcs = { - 'modified': diff.createModifiedFileTabs, - 'commit': diff.createCommitFileTabs, - 'separate': diff.createSeparateTabs, - 'single': diff.createSingleTab, + 'modified': diff_window.createModifiedFileTabs, + 'commit': diff_window.createCommitFileTabs, + 'separate': diff_window.createSeparateTabs, + 'single': diff_window.createSingleTab, } mode = 'single' opts = {} @@ -2065,28 +294,28 @@ also retrieve revisions of files from several VCSs for comparison and merging.'' # start a new tab mode = 'single' if 'vcs' in options: - diff.prefs.setString('vcs_search_order', options['vcs']) - diff.preferences_updated() + diff_window.prefs.setString('vcs_search_order', options['vcs']) + diff_window.preferences_updated() if 'ignore-space-change' in options: - diff.prefs.setBool('display_ignore_whitespace_changes', True) - diff.prefs.setBool('align_ignore_whitespace_changes', True) - diff.preferences_updated() + diff_window.prefs.setBool('display_ignore_whitespace_changes', True) + diff_window.prefs.setBool('align_ignore_whitespace_changes', True) + diff_window.preferences_updated() if 'ignore-blank-lines' in options: - diff.prefs.setBool('display_ignore_blanklines', True) - diff.prefs.setBool('align_ignore_blanklines', True) - diff.preferences_updated() + diff_window.prefs.setBool('display_ignore_blanklines', True) + diff_window.prefs.setBool('align_ignore_blanklines', True) + diff_window.preferences_updated() if 'ignore-end-of-line' in options: - diff.prefs.setBool('display_ignore_endofline', True) - diff.prefs.setBool('align_ignore_endofline', True) - diff.preferences_updated() + diff_window.prefs.setBool('display_ignore_endofline', True) + diff_window.prefs.setBool('align_ignore_endofline', True) + diff_window.preferences_updated() if 'ignore-case' in options: - diff.prefs.setBool('display_ignore_case', True) - diff.prefs.setBool('align_ignore_case', True) - diff.preferences_updated() + diff_window.prefs.setBool('display_ignore_case', True) + diff_window.prefs.setBool('align_ignore_case', True) + diff_window.preferences_updated() if 'ignore-all-space' in options: - diff.prefs.setBool('display_ignore_whitespace', True) - diff.prefs.setBool('align_ignore_whitespace', True) - diff.preferences_updated() + diff_window.prefs.setBool('display_ignore_whitespace', True) + diff_window.prefs.setBool('align_ignore_whitespace', True) + diff_window.preferences_updated() if 'label' in options: labels.append(options['label']) if 'line' in options: @@ -2103,7 +332,7 @@ also retrieve revisions of files from several VCSs for comparison and merging.'' revs = [] had_specs = True for arg in command_line.get_arguments()[1:]: - filename = diff.prefs.convertToNativePath(arg) + filename = diff_window.prefs.convertToNativePath(arg) if (mode == 'single' or mode == 'separate') and os.path.isdir(filename): if len(specs) > 0: filename = os.path.join(filename, os.path.basename(specs[-1][0])) @@ -2121,23 +350,26 @@ also retrieve revisions of files from several VCSs for comparison and merging.'' specs.append((os.curdir, [(None, encoding)])) had_specs = True funcs[mode](specs, labels, opts) + # create a file diff viewer if the command line arguments haven't # implicitly created any if not had_specs: - diff.newLoadedFileDiffViewer([]) + diff_window.newLoadedFileDiffViewer([]) elif close_on_same: - diff.closeOnSame() - nb = diff.notebook + diff_window.closeOnSame() + nb = diff_window.notebook n = nb.get_n_pages() if n > 0: - nb.set_show_tabs(diff.prefs.getBool('tabs_always_show') or n > 1) + nb.set_show_tabs(diff_window.prefs.getBool('tabs_always_show') or n > 1) nb.get_nth_page(0).grab_focus() + self.activate() return 0 -def main(version, sysconfigdir): +def main(version: str, sysconfigdir: str) -> int: + """The application's entry point.""" constants.VERSION = version - app = Application(sysconfigdir) + app = DiffuseApplication(sysconfigdir) return app.run(sys.argv) diff --git a/src/diffuse/meson.build b/src/diffuse/meson.build index 7ed885e..c8ff065 100644 --- a/src/diffuse/meson.build +++ b/src/diffuse/meson.build @@ -52,6 +52,7 @@ diffuse_sources = [ 'resources.py', 'utils.py', 'widgets.py', + 'window.py', ] install_data(diffuse_sources, install_dir: moduledir) diff --git a/src/diffuse/window.py b/src/diffuse/window.py new file mode 100644 index 0000000..ad1247f --- /dev/null +++ b/src/diffuse/window.py @@ -0,0 +1,1821 @@ +# Diffuse: a graphical tool for merging and comparing text files. +# +# Copyright (C) 2019 Derrick Moser +# Copyright (C) 2022 Romain Failliot +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import os +import codecs +import shlex +import stat +import webbrowser + +from gettext import gettext as _ +from typing import List, Optional +from urllib.parse import urlparse + +from diffuse import constants, utils +from diffuse.dialogs import AboutDialog, FileChooserDialog, NumericDialog, SearchDialog +from diffuse.preferences import Preferences +from diffuse.resources import theResources +from diffuse.utils import LineEnding +from diffuse.vcs.vcs_interface import VcsInterface +from diffuse.vcs.vcs_registry import VcsRegistry +from diffuse.widgets import FileDiffViewerBase, EditMode + +import gi # type: ignore +gi.require_version('Gdk', '3.0') +gi.require_version('GObject', '2.0') +gi.require_version('GdkPixbuf', '2.0') +gi.require_version('Gtk', '3.0') +gi.require_version('Pango', '1.0') +gi.require_version('PangoCairo', '1.0') +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GObject, Gtk, Pango # type: ignore # noqa: E402, E501 + + +theVCSs = VcsRegistry() + + +class NotebookTab(Gtk.EventBox): + """Notebook tab widget. + + A notebook tab with labels and a close button. Use `notebooktab.button.connect()` + to be notified when the button is pressed. Make this a Gtk.EventBox so + signals can be connected for MMB and RMB button presses. + """ + + def __init__(self, name: str, stock: str) -> None: + Gtk.EventBox.__init__(self) + self.set_visible_window(False) + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + if stock is not None: + image = Gtk.Image() + image.set_from_stock(stock, Gtk.IconSize.MENU) + hbox.pack_start(image, False, False, 5) + image.show() + + label = Gtk.Label(label=name) + # left justify the widget + label.set_xalign(0.0) + label.set_yalign(0.5) + hbox.pack_start(label, True, True, 0) + label.show() + self.label = label + + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + image = Gtk.Image() + image.set_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU) + button.add(image) + image.show() + button.set_tooltip_text(_('Close Tab')) + hbox.pack_start(button, False, False, 0) + button.show() + self.button = button + + self.add(hbox) + hbox.show() + + def get_text(self) -> str: + return self.label.get_text() + + def set_text(self, s: str) -> None: + self.label.set_text(s) + + +class FileInfo: + """Contains information about a file.""" + + def __init__(self, name=None, encoding=None, vcs=None, revision=None, label=None): + # file name + self.name = name + # name of codec used to translate the file contents to unicode text + self.encoding = encoding + # the VCS object + self.vcs: VcsInterface = vcs + # revision used to retrieve file from the VCS + self.revision = revision + # alternate text to display instead of the actual file name + self.label = label + # 'stat' for files read from disk -- used to warn about changes to the + # file on disk before saving + self.stat = None + # most recent 'stat' for files read from disk -- used on focus change + # to warn about changes to file on disk + self.last_stat = None + + +class PaneHeader(Gtk.Box): + """The pane header widget.""" + + def __init__(self) -> None: + Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + _append_buttons(self, Gtk.IconSize.MENU, [ + [Gtk.STOCK_OPEN, self.button_cb, 'open', _('Open File...')], + [Gtk.STOCK_REFRESH, self.button_cb, 'reload', _('Reload File')], + [Gtk.STOCK_SAVE, self.button_cb, 'save', _('Save File')], + [Gtk.STOCK_SAVE_AS, self.button_cb, 'save_as', _('Save File As...')]]) + + self.label = label = Gtk.Label() + label.set_selectable(True) + label.set_ellipsize(Pango.EllipsizeMode.START) + label.set_max_width_chars(1) + + self.pack_start(label, True, True, 0) + + # file's name and information about how to retrieve it from a + # VCS + self.info = FileInfo() + self.has_edits = False + self.updateTitle() + self.show_all() + + # callback for buttons + def button_cb(self, widget, s): + self.emit(s) + + # creates an appropriate title for the pane header + def updateTitle(self) -> None: + ss = [] + info = self.info + if info.label is not None: + # show the provided label instead of the file name + ss.append(info.label) + else: + if info.name is not None: + ss.append(info.name) + if info.revision is not None: + ss.append('(' + info.revision + ')') + if self.has_edits: + ss.append('*') + s = ' '.join(ss) + self.label.set_text(s) + self.label.set_tooltip_text(s) + self.emit('title_changed') + + # set num edits + def setEdits(self, has_edits: bool) -> None: + if self.has_edits != has_edits: + self.has_edits = has_edits + self.updateTitle() + + # Has the file on disk changed since last time it was loaded? + def has_file_changed_on_disk(self) -> bool: + if self.info.last_stat is not None: + try: + new_stat = os.stat(self.info.name) + if self.info.last_stat[stat.ST_MTIME] < new_stat[stat.ST_MTIME]: + # update our notion of the most recent modification + self.info.last_stat = new_stat + return True + except OSError: + return False + return False + + +class PaneFooter(Gtk.Box): + """The pane footer.""" + + def __init__(self) -> None: + Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + self.cursor = label = Gtk.Label() + self.cursor.set_size_request(-1, -1) + self.pack_start(label, False, False, 0) + + separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + self.pack_end(separator, False, False, 10) + + self.encoding = label = Gtk.Label() + self.pack_end(label, False, False, 0) + + separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + self.pack_end(separator, False, False, 10) + + self.format = label = Gtk.Label() + self.pack_end(label, False, False, 0) + + separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + self.pack_end(separator, False, False, 10) + + self.set_size_request(0, self.get_size_request()[1]) + self.show_all() + + # set the cursor label + def updateCursor(self, viewer: FileDiffViewerBase, f: int) -> None: + if viewer.mode == EditMode.CHAR and viewer.current_pane == f: + # # TODO: Find a fix for the column bug (resizing issue when editing a line) + # j = viewer.current_char + # if j > 0: + # text = viewer.getLineText(viewer.current_pane, viewer.current_line)[:j] + # j = viewer.stringWidth(text) + # s = _('Column %d') % (j, ) + # + # In the meantime, keep this line of code in order to keep the translations + s = _('Column %d') % (0, ) + s = '' + else: + s = '' + self.cursor.set_text(s) + + # set the format label + def setFormat(self, s: LineEnding) -> None: + v = [] + if s & LineEnding.DOS_FORMAT: + v.append('DOS') + if s & LineEnding.MAC_FORMAT: + v.append('Mac') + if s & LineEnding.UNIX_FORMAT: + v.append('Unix') + self.format.set_text('/'.join(v)) + + # set the format label + def setEncoding(self, s: str) -> None: + if s is None: + s = '' + self.encoding.set_text(s) + + +class FileDiffViewer(FileDiffViewerBase): + """Specialization of FileDiffViewerBase for Diffuse.""" + + def __init__(self, n: int, prefs: Preferences, title: str) -> None: + super().__init__(n, prefs) + + self.title = title + self.status: Optional[str] = '' + + self.headers: List[PaneHeader] = [] + self.footers: List[PaneFooter] = [] + for i in range(n): + # pane header + w = PaneHeader() + self.headers.append(w) + self.attach(w, i, 0, 1, 1) + w.connect('title-changed', self.title_changed_cb) + w.connect('open', self.open_file_button_cb, i) + w.connect('reload', self.reload_file_button_cb, i) + w.connect('save', self.save_file_button_cb, i) + w.connect('save-as', self.save_file_as_button_cb, i) + w.show() + + # pane footer + w = PaneFooter() + self.footers.append(w) + self.attach(w, i, 2, 1, 1) + w.show() + + self.connect('swapped-panes', self.swapped_panes_cb) + self.connect('num-edits-changed', self.num_edits_changed_cb) + self.connect('mode-changed', self.mode_changed_cb) + self.connect('cursor-changed', self.cursor_changed_cb) + self.connect('format-changed', self.format_changed_cb) + + for i, darea in enumerate(self.dareas): + darea.drag_dest_set( + Gtk.DestDefaults.ALL, + [Gtk.TargetEntry.new('text/uri-list', 0, 0)], + Gdk.DragAction.COPY + ) + darea.connect('drag-data-received', self.drag_data_received_cb, i) + # initialise status + self.updateStatus() + + # convenience method to request confirmation before loading a file if + # it will cause existing edits to be lost + def loadFromInfo(self, f: int, info: FileInfo) -> None: + if self.headers[f].has_edits: + # warn users of any unsaved changes they might lose + dialog = Gtk.MessageDialog( + self.get_toplevel(), + Gtk.DialogFlags.DESTROY_WITH_PARENT, + Gtk.MessageType.WARNING, + Gtk.ButtonsType.NONE, _('Save changes before loading the new file?') + ) + dialog.set_title(constants.APP_NAME) + dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) + dialog.add_button(Gtk.STOCK_NO, Gtk.ResponseType.REJECT) + dialog.add_button(Gtk.STOCK_YES, Gtk.ResponseType.OK) + dialog.set_default_response(Gtk.ResponseType.CANCEL) + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.OK: + # save the current pane contents + if not self.save_file(f): + # cancel if the save failed + return + elif response != Gtk.ResponseType.REJECT: + # cancel if the user did not choose 'yes' or 'no' + return + self.openUndoBlock() + self.recordEditMode() + self.load(f, info) + self.recordEditMode() + self.closeUndoBlock() + + # callback used when receiving drag-n-drop data + def drag_data_received_cb(self, widget, context, x, y, selection, targettype, eventtime, f): + # get uri list + uris = selection.get_uris() + # load the first valid file + for uri in uris: + path = urlparse(uri).path + if os.path.isfile(path): + self.loadFromInfo(f, FileInfo(path)) + break + + # change the file info for pane 'f' to 'info' + def setFileInfo(self, f, info): + h, footer = self.headers[f], self.footers[f] + h.info = info + h.updateTitle() + footer.setFormat(self.panes[f].format) + footer.setEncoding(info.encoding) + footer.updateCursor(self, f) + + # callback used when a pane header's title changes + def title_changed_cb(self, widget): + # choose a short but descriptive title for the viewer + has_edits = False + names = [] + unique_names = set() + for header in self.headers: + has_edits |= header.has_edits + s = header.info.label + if s is None: + # no label provided, show the file name instead + s = header.info.name + if s is not None: + s = os.path.basename(s) + if s is not None: + names.append(s) + unique_names.add(s) + + if len(unique_names) > 0: + if len(unique_names) == 1: + self.title = names[0] + else: + self.title = ' : '.join(names) + s = self.title + if has_edits: + s += ' *' + self.emit('title_changed', s) + + def setEncoding(self, f, encoding): + h = self.headers[f] + h.info.encoding = encoding + self.footers[f].setEncoding(encoding) + + # load a new file into pane 'f' + # 'info' indicates the name of the file and how to retrieve it from the + # version control system if applicable + def load(self, f: int, info: FileInfo) -> None: + name = info.name + encoding = info.encoding + stat = None + if name is None: + # reset to an empty pane + ss = [] + else: + rev = info.revision + try: + if rev is None: + # load the contents of a plain file + with open(name, 'rb') as fd: + contents = fd.read() + # get the file's modification times so we can detect changes + stat = os.stat(name) + else: + if info.vcs is None: + raise IOError('Not under version control.') + fullname = os.path.abspath(name) + # retrieve the revision from the version control system + contents = info.vcs.getRevision(self.prefs, fullname, rev) + # convert file contents to unicode + if encoding is None: + s, encoding = self.prefs.convertToUnicode(contents) + else: + s = str(contents, encoding=encoding) + ss = utils.splitlines(s) + except (IOError, OSError, UnicodeDecodeError, LookupError): + # FIXME: this can occur before the toplevel window is drawn + if rev is not None: + msg = _( + 'Error reading revision %(rev)s of %(file)s.' + ) % {'rev': rev, 'file': name} + else: + msg = _('Error reading %s.') % (name, ) + utils.logErrorAndDialog(msg, self.get_toplevel()) + return + # update the panes contents, last modified time, and title + self.replaceContents(f, ss) + info.encoding = encoding + info.last_stat = info.stat = stat + self.setFileInfo(f, info) + # use the file name to choose appropriate syntax highlighting rules + if name is not None: + syntax = theResources.guessSyntaxForFile(name, ss) + if syntax is not None: + self.setSyntax(syntax) + + # load a new file into pane 'f' + def open_file(self, f: int, reload: bool = False) -> None: + h = self.headers[f] + info = h.info + if not reload: + # we need to ask for a file name if we are not reloading the + # existing file + dialog = FileChooserDialog( + _('Open File'), + self.get_toplevel(), + self.prefs, + Gtk.FileChooserAction.OPEN, + Gtk.STOCK_OPEN, + True + ) + if info.name is not None: + dialog.set_filename(os.path.realpath(info.name)) + dialog.set_encoding(info.encoding) + dialog.set_default_response(Gtk.ResponseType.OK) + end = (dialog.run() != Gtk.ResponseType.OK) + name = dialog.get_filename() + rev = None + vcs = None + revision = dialog.get_revision().strip() + if revision != '': + rev = revision + vcs = theVCSs.findByFilename(name, self.prefs) + info = FileInfo(name, dialog.get_encoding(), vcs, rev) + dialog.destroy() + if end: + return + self.loadFromInfo(f, info) + + # callback for open file button + def open_file_button_cb(self, widget, f): + self.open_file(f) + + # callback for open file menu item + def open_file_cb(self, widget, data): + self.open_file(self.current_pane) + + # callback for reload file button + def reload_file_button_cb(self, widget, f): + self.open_file(f, True) + + # callback for reload file menu item + def reload_file_cb(self, widget, data): + self.open_file(self.current_pane, True) + + # save contents of pane 'f' to file + def save_file(self, f: int, save_as: bool = False) -> bool: + h = self.headers[f] + info = h.info + name, encoding, rev, label = info.name, info.encoding, info.revision, info.label + if name is None or rev is not None: + # we need to prompt for a file name the current contents were + # not loaded from a regular file + save_as = True + if save_as: + # prompt for a file name + dialog = FileChooserDialog( + _('Save %(title)s Pane %(pane)d') % {'title': self.title, 'pane': f + 1}, + self.get_toplevel(), + self.prefs, + Gtk.FileChooserAction.SAVE, + Gtk.STOCK_SAVE + ) + if name is not None: + dialog.set_filename(os.path.abspath(name)) + if encoding is None: + encoding = self.prefs.getDefaultEncoding() + dialog.set_encoding(encoding) + name, label = None, None + dialog.set_default_response(Gtk.ResponseType.OK) + if dialog.run() == Gtk.ResponseType.OK: + name = dialog.get_filename() + encoding = dialog.get_encoding() + if encoding is None: + if info.encoding is not None: + # this case can occur if the user provided the + # encoding and it is not an encoding we know about + encoding = info.encoding + else: + encoding = self.prefs.getDefaultEncoding() + dialog.destroy() + if name is None: + return False + try: + msg = None + # warn if we are about to overwrite an existing file + if save_as: + if os.path.exists(name): + msg = _( + 'A file named %s already exists. Do you want to overwrite it?' + ) % (name, ) + # warn if we are about to overwrite a file that has changed + # since we last read it + elif info.stat is not None: + if info.stat[stat.ST_MTIME] < os.stat(name)[stat.ST_MTIME]: + msg = _( + 'The file %s has been modified by another process since reading it. ' + 'If you save, all the external changes could be lost. Save anyways?' + ) % (name, ) + if msg is not None: + dialog = utils.MessageDialog(self.get_toplevel(), Gtk.MessageType.QUESTION, msg) + end = (dialog.run() != Gtk.ResponseType.OK) + dialog.destroy() + if end: + return False + except OSError: + pass + try: + # convert the text to the output encoding + # refresh the lines to contain new objects with updated line + # numbers and no local edits + ss = [] + for line in self.panes[f].lines: + if line is not None: + s = line.getText() + if s is not None: + ss.append(s) + encoded = codecs.encode(''.join(ss), encoding) + + # write file + with open(name, 'wb') as fd: + fd.write(encoded) + + # make the edits look permanent + self.openUndoBlock() + self.bakeEdits(f) + self.closeUndoBlock() + # update the pane file info + info.name, info.encoding, info.revision, info.label = name, encoding, None, label + info.last_stat = info.stat = os.stat(name) + self.setFileInfo(f, info) + # update the syntax highlighting in case we changed the file extension + syntax = theResources.guessSyntaxForFile(name, ss) + if syntax is not None: + self.setSyntax(syntax) + return True + except (UnicodeEncodeError, LookupError): + utils.logErrorAndDialog( + _('Error encoding to %s.') % (encoding, ), + self.get_toplevel() + ) + except IOError: + utils.logErrorAndDialog(_('Error writing %s.') % (name, ), self.get_toplevel()) + return False + + # callback for save file menu item + def save_file_cb(self, widget, data): + self.save_file(self.current_pane) + + # callback for save file as menu item + def save_file_as_cb(self, widget, data): + self.save_file(self.current_pane, True) + + # callback for save all menu item + def save_all_cb(self, widget, data): + for f, h in enumerate(self.headers): + if h.has_edits: + self.save_file(f) + + # callback for save file button + def save_file_button_cb(self, widget, f): + self.save_file(f) + + # callback for save file as button + def save_file_as_button_cb(self, widget, f): + self.save_file(f, True) + + # callback for go to line menu item + def go_to_line_cb(self, widget, data): + parent = self.get_toplevel() + dialog = NumericDialog( + parent, + _('Go To Line...'), + _('Line Number: '), + val=1, + lower=1, + upper=self.panes[self.current_pane].max_line_number + 1 + ) + okay = (dialog.run() == Gtk.ResponseType.ACCEPT) + i = dialog.get_value() + dialog.destroy() + if okay: + self.go_to_line(i) + + # callback to receive notification when the name of a file changes + def swapped_panes_cb(self, widget, f_dst, f_src): + f0, f1 = self.headers[f_dst], self.headers[f_src] + f0.has_edits, f1.has_edits = f1.has_edits, f0.has_edits + info0, info1 = f1.info, f0.info + self.setFileInfo(f_dst, info0) + self.setFileInfo(f_src, info1) + + # callback to receive notification when the name of a file changes + def num_edits_changed_cb(self, widget, f): + self.headers[f].setEdits(self.panes[f].num_edits > 0) + + # callback to record changes to the viewer's mode + def mode_changed_cb(self, widget): + self.updateStatus() + + # update the viewer's current status message + def updateStatus(self) -> None: + if self.mode == EditMode.LINE: + s = _( + 'Press the enter key or double click to edit. Press the space bar or use the ' + 'RMB menu to manually align.' + ) + elif self.mode == EditMode.CHAR: + s = _('Press the escape key to finish editing.') + elif self.mode == EditMode.ALIGN: + s = _( + 'Select target line and press the space bar to align. Press the escape key to ' + 'cancel.' + ) + else: + s = None + self.status = s + self.emit('status_changed', s) + + # gets the status bar text + def getStatus(self) -> Optional[str]: + return self.status + + # callback to display the cursor in a pane + def cursor_changed_cb(self, widget): + for f, footer in enumerate(self.footers): + footer.updateCursor(self, f) + + # callback to display the format of a pane + def format_changed_cb(self, widget, f, fmt): + self.footers[f].setFormat(fmt) + + +class DiffuseWindow(Gtk.ApplicationWindow): + """The application window class. + + Contains a set of file viewers. This class displays tab for switching + between viewers and dispatches menu commands to the current viewer. + """ + + def __init__(self, rc_dir, **kwargs): + super().__init__(type=Gtk.WindowType.TOPLEVEL, **kwargs) + + self.prefs = Preferences(os.path.join(rc_dir, 'prefs')) + # number of created viewers (used to label some tabs) + self.viewer_count = 0 + + # get monitor resolution + monitor_geometry = Gdk.Display.get_default().get_monitor(0).get_geometry() + + # state information that should persist across sessions + self.bool_state = { + 'window_maximized': False, + 'search_matchcase': False, + 'search_backwards': False + } + self.int_state = {'window_width': 1024, 'window_height': 768} + self.int_state['window_x'] = max( + 0, + (monitor_geometry.width - self.int_state['window_width']) / 2 + ) + self.int_state['window_y'] = max( + 0, + (monitor_geometry.height - self.int_state['window_height']) / 2 + ) + self.connect('configure-event', self.configure_cb) + self.connect('window-state-event', self.window_state_cb) + + # search history is application wide + self.search_pattern: Optional[str] = None + self.search_history: List[str] = [] + + self.connect('delete-event', self.delete_cb) + + # create a Box for our contents + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + + # create some custom icons for merging + DIFFUSE_STOCK_NEW_2WAY_MERGE = 'diffuse-new-2way-merge' + DIFFUSE_STOCK_NEW_3WAY_MERGE = 'diffuse-new-3way-merge' + DIFFUSE_STOCK_LEFT_RIGHT = 'diffuse-left-right' + DIFFUSE_STOCK_RIGHT_LEFT = 'diffuse-right-left' + + # get default theme and window scale factor + default_theme = Gtk.IconTheme.get_default() + scale_factor = self.get_scale_factor() + + icon_size = Gtk.IconSize.lookup(Gtk.IconSize.LARGE_TOOLBAR).height + factory = Gtk.IconFactory() + + # render the base item used to indicate a new document + p0 = default_theme.load_icon_for_scale('document-new', icon_size, scale_factor, 0) + w, h = p0.get_width(), p0.get_height() + + # render new 2-way merge icon + s = 0.8 + sw, sh = int(s * w), int(s * h) + w1, h1 = w - sw, h - sh + p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) + p.fill(0) + p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + p0.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + factory.add(DIFFUSE_STOCK_NEW_2WAY_MERGE, Gtk.IconSet.new_from_pixbuf(p)) + + # render new 3-way merge icon + s = 0.7 + sw, sh = int(s * w), int(s * h) + w1, h1 = (w - sw) / 2, (h - sh) / 2 + w2, h2 = w - sw, h - sh + p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) + p.fill(0) + p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + p0.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + p0.composite(p, w2, h2, sw, sh, w2, h2, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + factory.add(DIFFUSE_STOCK_NEW_3WAY_MERGE, Gtk.IconSet.new_from_pixbuf(p)) + + # render the left and right arrow we will use in our custom icons + p0 = default_theme.load_icon_for_scale('go-next', icon_size, scale_factor, 0) + p1 = default_theme.load_icon_for_scale('go-previous', icon_size, scale_factor, 0) + w, h, s = p0.get_width(), p0.get_height(), 0.65 + sw, sh = int(s * w), int(s * h) + w1, h1 = w - sw, h - sh + + # create merge from left then right icon + p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) + p.fill(0) + p1.composite(p, w1, h1, sw, sh, w1, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + p0.composite(p, 0, 0, sw, sh, 0, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + factory.add(DIFFUSE_STOCK_LEFT_RIGHT, Gtk.IconSet.new_from_pixbuf(p)) + + # create merge from right then left icon + p = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, True, 8, w, h) + p.fill(0) + p0.composite(p, 0, h1, sw, sh, 0, h1, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + p1.composite(p, w1, 0, sw, sh, w1, 0, s, s, GdkPixbuf.InterpType.BILINEAR, 255) + factory.add(DIFFUSE_STOCK_RIGHT_LEFT, Gtk.IconSet.new_from_pixbuf(p)) + + # make the icons available for use + factory.add_default() + + menuspecs = [] + menuspecs.append([_('_File'), [ + [ + [_('_Open File...'), self.open_file_cb, None, 'open_file'], + [_('Open File In New _Tab...'), self.open_file_in_new_tab_cb, None, 'open_file_in_new_tab'], # noqa: E501 + [_('Open _Modified Files...'), self.open_modified_files_cb, None, 'open_modified_files'], # noqa: E501 + [_('Open Commi_t...'), self.open_commit_cb, None, 'open_commit'], + [_('_Reload File'), self.reload_file_cb, None, 'reload_file'], + ], [ + [_('_Save File'), self.save_file_cb, None, 'save_file'], + [_('Save File _As...'), self.save_file_as_cb, None, 'save_file_as'], + [_('Save A_ll'), self.save_all_cb, None, 'save_all'], + ], [ + [_('New _2-Way File Merge'), self.new_2_way_file_merge_cb, None, 'new_2_way_file_merge'], # noqa: E501 + [_('New _3-Way File Merge'), self.new_3_way_file_merge_cb, None, 'new_3_way_file_merge'], # noqa: E501 + [_('New _N-Way File Merge...'), self.new_n_way_file_merge_cb, None, 'new_n_way_file_merge'], # noqa: E501 + ], [ + [_('_Close Tab'), self.close_tab_cb, None, 'close_tab'], + [_('_Undo Close Tab'), self.undo_close_tab_cb, None, 'undo_close_tab'], + [_('_Quit'), self.quit_cb, None, 'quit'] + ] + ]]) + + menuspecs.append([_('_Edit'), [ + [ + [_('_Undo'), self.button_cb, 'undo', 'undo'], + [_('_Redo'), self.button_cb, 'redo', 'redo'], + ], [ + [_('Cu_t'), self.button_cb, 'cut', 'cut'], + [_('_Copy'), self.button_cb, 'copy', 'copy'], + [_('_Paste'), self.button_cb, 'paste', 'paste'], + ], [ + [_('Select _All'), self.button_cb, 'select_all', 'select_all'], + [_('C_lear Edits'), self.button_cb, 'clear_edits', 'clear_edits'], + [_('_Dismiss All Edits'), self.button_cb, 'dismiss_all_edits', 'dismiss_all_edits'], + ], [ + [_('_Find...'), self.find_cb, None, 'find'], + [_('Find _Next'), self.find_next_cb, None, 'find_next'], + [_('Find Pre_vious'), self.find_previous_cb, None, 'find_previous'], + [_('_Go To Line...'), self.go_to_line_cb, None, 'go_to_line'], + ], [ + [_('Pr_eferences...'), self.preferences_cb, None, 'preferences'] + ] + ]]) + + syntax_menu = [[[_('None'), None, '', 'syntax_highlighting']]] + names = theResources.getSyntaxNames() + variant = GLib.Variant.new_string('') + self.syntax_action = Gio.SimpleAction.new_stateful( + 'syntax_highlighting', variant.get_type(), variant + ) + self.syntax_action.connect('change-state', self.syntax_cb) + self.add_action(self.syntax_action) + if len(names) > 0: + syntax_section = [] + names.sort(key=str.lower) + for name in names: + syntax_section.append( + [ + name, + None, + name, + 'syntax_highlighting', + ] + ) + syntax_menu.append(syntax_section) + + menuspecs.append([_('_View'), [ + [ + [_('_Syntax Highlighting'), None, None, None, syntax_menu] + ], [ + [_('Re_align All'), self.button_cb, 'realign_all', 'realign_all'], + [_('_Isolate'), self.button_cb, 'isolate', 'isolate'], + ], [ + [_('_First Difference'), self.button_cb, 'first_difference', 'first_difference'], + [_('_Previous Difference'), self.button_cb, 'previous_difference', 'previous_difference'], # noqa: E501 + [_('_Next Difference'), self.button_cb, 'next_difference', 'next_difference'], + [_('_Last Difference'), self.button_cb, 'last_difference', 'last_difference'], + ], [ + [_('Fir_st Tab'), self.first_tab_cb, None, 'first_tab'], + [_('Pre_vious Tab'), self.previous_tab_cb, None, 'previous_tab'], + [_('Next _Tab'), self.next_tab_cb, None, 'next_tab'], + [_('Las_t Tab'), self.last_tab_cb, None, 'last_tab'], + ], [ + [_('Shift Pane _Right'), self.button_cb, 'shift_pane_right', 'shift_pane_right'], + [_('Shift Pane _Left'), self.button_cb, 'shift_pane_left', 'shift_pane_left'] + ] + ]]) + + menuspecs.append([_('F_ormat'), [ + [ + [_('Convert To _Upper Case'), self.button_cb, 'convert_to_upper_case', 'convert_to_upper_case'], # noqa: E501 + [_('Convert To _Lower Case'), self.button_cb, 'convert_to_lower_case', 'convert_to_lower_case'], # noqa: E501 + ], [ + [_('Sort Lines In _Ascending Order'), self.button_cb, 'sort_lines_in_ascending_order', 'sort_lines_in_ascending_order'], # noqa: E501 + [_('Sort Lines In D_escending Order'), self.button_cb, 'sort_lines_in_descending_order', 'sort_lines_in_descending_order'], # noqa: E501 + ], [ + [_('Remove Trailing _White Space'), self.button_cb, 'remove_trailing_white_space', 'remove_trailing_white_space'], # noqa: E501 + [_('Convert Tabs To _Spaces'), self.button_cb, 'convert_tabs_to_spaces', 'convert_tabs_to_spaces'], # noqa: E501 + [_('Convert Leading Spaces To _Tabs'), self.button_cb, 'convert_leading_spaces_to_tabs', 'convert_leading_spaces_to_tabs'], # noqa: E501 + ], [ + [_('_Increase Indenting'), self.button_cb, 'increase_indenting', 'increase_indenting'], # noqa: E501 + [_('De_crease Indenting'), self.button_cb, 'decrease_indenting', 'decrease_indenting'], # noqa: E501 + ], [ + [_('Convert To _DOS Format'), self.button_cb, 'convert_to_dos', 'convert_to_dos'], + [_('Convert To _Mac Format'), self.button_cb, 'convert_to_mac', 'convert_to_mac'], + [_('Convert To Uni_x Format'), self.button_cb, 'convert_to_unix', 'convert_to_unix'] + ] + ]]) + + menuspecs.append([_('_Merge'), [ + [ + [_('Copy Selection _Right'), self.button_cb, 'copy_selection_right', 'copy_selection_right'], # noqa: E501 + [_('Copy Selection _Left'), self.button_cb, 'copy_selection_left', 'copy_selection_left'], # noqa: E501 + ], [ + [_('Copy Left _Into Selection'), self.button_cb, 'copy_left_into_selection', 'copy_left_into_selection'], # noqa: E501 + [_('Copy Right I_nto Selection'), self.button_cb, 'copy_right_into_selection', 'copy_right_into_selection'], # noqa: E501 + [_('_Merge From Left Then Right'), self.button_cb, 'merge_from_left_then_right', 'merge_from_left_then_right'], # noqa: E501 + [_('M_erge From Right Then Left'), self.button_cb, 'merge_from_right_then_left', 'merge_from_right_then_left'] # noqa: E501 + ] + ]]) + + menuspecs.append([_('_Help'), [ + [ + [_('_Help Contents...'), self.help_contents_cb, None, 'help_contents'], + ], [ + [_('_About %s...') % (constants.APP_NAME, ), self.about_cb, None, 'about'] + ] + ]]) + + # used to disable menu events when switching tabs + self.menu_update_depth = 0 + + menubar = Gio.Menu() + for label, sections in menuspecs: + menubar.append_submenu(label, self._create_menu(sections)) + self.get_application().set_menubar(menubar) + + # create button bar + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + _append_buttons(hbox, Gtk.IconSize.LARGE_TOOLBAR, [ + [DIFFUSE_STOCK_NEW_2WAY_MERGE, self.new_2_way_file_merge_cb, None, _('New 2-Way File Merge')], # noqa: E501 + [DIFFUSE_STOCK_NEW_3WAY_MERGE, self.new_3_way_file_merge_cb, None, _('New 3-Way File Merge')], # noqa: E501 + [], + [Gtk.STOCK_EXECUTE, self.button_cb, 'realign_all', _('Realign All')], + [Gtk.STOCK_GOTO_TOP, self.button_cb, 'first_difference', _('First Difference')], + [Gtk.STOCK_GO_UP, self.button_cb, 'previous_difference', _('Previous Difference')], + [Gtk.STOCK_GO_DOWN, self.button_cb, 'next_difference', _('Next Difference')], + [Gtk.STOCK_GOTO_BOTTOM, self.button_cb, 'last_difference', _('Last Difference')], + [], + [Gtk.STOCK_GOTO_LAST, self.button_cb, 'copy_selection_right', _('Copy Selection Right')], # noqa: E501 + [Gtk.STOCK_GOTO_FIRST, self.button_cb, 'copy_selection_left', _('Copy Selection Left')], + [Gtk.STOCK_GO_FORWARD, self.button_cb, 'copy_left_into_selection', _('Copy Left Into Selection')], # noqa: E501 + [Gtk.STOCK_GO_BACK, self.button_cb, 'copy_right_into_selection', _('Copy Right Into Selection')], # noqa: E501 + [DIFFUSE_STOCK_LEFT_RIGHT, self.button_cb, 'merge_from_left_then_right', _('Merge From Left Then Right')], # noqa: E501 + [DIFFUSE_STOCK_RIGHT_LEFT, self.button_cb, 'merge_from_right_then_left', _('Merge From Right Then Left')], # noqa: E501 + [], + [Gtk.STOCK_UNDO, self.button_cb, 'undo', _('Undo')], + [Gtk.STOCK_REDO, self.button_cb, 'redo', _('Redo')], + [Gtk.STOCK_CUT, self.button_cb, 'cut', _('Cut')], + [Gtk.STOCK_COPY, self.button_cb, 'copy', _('Copy')], + [Gtk.STOCK_PASTE, self.button_cb, 'paste', _('Paste')], + [Gtk.STOCK_CLEAR, self.button_cb, 'clear_edits', _('Clear Edits')] + ]) + # avoid the button bar from dictating the minimum window size + hbox.set_size_request(0, hbox.get_size_request()[1]) + vbox.pack_start(hbox, False, False, 0) + hbox.show() + + self.closed_tabs = [] + self.notebook = notebook = Gtk.Notebook() + notebook.set_scrollable(True) + notebook.connect('switch-page', self.switch_page_cb) + vbox.pack_start(notebook, True, True, 0) + notebook.show() + + # Add a status bar to the bottom + self.statusbar = statusbar = Gtk.Statusbar() + vbox.pack_start(statusbar, False, False, 0) + statusbar.show() + + self.add(vbox) + vbox.show() + self.connect('focus-in-event', self.focus_in_cb) + + def _create_menu(self, sections): + menu = Gio.Menu.new() + for section in sections: + section_menu = Gio.Menu.new() + for label, cb, cb_data, accel, *submenu in section: + if submenu: + (submenu,) = submenu + section_menu.append_submenu(label, self._create_menu(submenu)) + else: + if cb_data is not None: + cb_data = GLib.Variant.new_string(cb_data) + accel = accel.replace('_', '-') + if cb is not None: + action = Gio.SimpleAction.new(accel, cb_data and cb_data.get_type()) + action.connect('activate', cb) + self.add_action(action) + item = Gio.MenuItem.new(label) + item.set_action_and_target_value('win.' + accel, cb_data) + key_binding = theResources.getKeyBindings('menu', accel) + if len(key_binding) > 0: + key, modifier = key_binding[0] + self.get_application().set_accels_for_action( + Gio.Action.print_detailed_name('win.' + accel, cb_data), + [Gtk.accelerator_name(key, modifier)], + ) + section_menu.append_item(item) + menu.append_section(None, section_menu) + return menu + + # notifies all viewers on focus changes so they may check for external + # changes to files + def focus_in_cb(self, widget, event): + changed = [] + for i in range(self.notebook.get_n_pages()): + page = self.notebook.get_nth_page(i) + for f, h in enumerate(page.headers): + if h.has_file_changed_on_disk(): + changed.append((page, f)) + + if changed: + filenames = [] + for (page, f) in changed: + h = page.headers[f] + filename = h.info.label if h.info.label is not None else h.info.name + filenames.append(filename) + + primary_text = _('Changes detected') + secondary_text = '' + if len(filenames) == 1: + secondary_text = _( + 'The file "%s" changed on disk.\n\n' + 'Do you want to reload the file?' + ) % (filenames[0],) + else: + secondary_text = _( + 'The following files changed on disk:\n%s\n\n' + 'Do you want to reload these files?' + ) % ('\n'.join('- ' + filename for filename in filenames),) + + dialog = Gtk.MessageDialog( + transient_for=self.get_toplevel(), + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=primary_text) + dialog.format_secondary_text(secondary_text) + dialog.set_default_response(Gtk.ResponseType.YES) + + button = dialog.get_widget_for_response(Gtk.ResponseType.YES) + button.get_style_context().add_class(Gtk.STYLE_CLASS_SUGGESTED_ACTION) + + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.YES: + for page, f in changed: + page.open_file(f, True) + + # record the window's position and size + def configure_cb(self, widget, event): + # read the state directly instead of using window_maximized as the order + # of configure/window_state events is undefined + if (widget.get_window().get_state() & Gdk.WindowState.MAXIMIZED) == 0: + self.int_state['window_x'], self.int_state['window_y'] = widget.get_window().get_root_origin() # noqa: E501 + self.int_state['window_width'] = event.width + self.int_state['window_height'] = event.height + + # record the window's maximized state + def window_state_cb(self, window, event): + self.bool_state['window_maximized'] = ( + (event.new_window_state & Gdk.WindowState.MAXIMIZED) != 0 + ) + + # load state information that should persist across sessions + def loadState(self, statepath: str) -> None: + if os.path.isfile(statepath): + try: + f = open(statepath, 'r') + ss = utils.readlines(f) + f.close() + for j, s in enumerate(ss): + try: + a = shlex.split(s, True) + if len(a) > 0: + if len(a) == 2 and a[0] in self.bool_state: + self.bool_state[a[0]] = (a[1] == 'True') + elif len(a) == 2 and a[0] in self.int_state: + self.int_state[a[0]] = int(a[1]) + else: + raise ValueError() + except ValueError: + # this may happen if the state was written by a + # different version -- don't bother the user + utils.logDebug(f'Error processing line {j + 1} of {statepath}.') + except IOError: + # bad $HOME value? -- don't bother the user + utils.logDebug(f'Error reading {statepath}.') + + self.move(self.int_state['window_x'], self.int_state['window_y']) + self.resize(self.int_state['window_width'], self.int_state['window_height']) + if self.bool_state['window_maximized']: + self.maximize() + + # save state information that should persist across sessions + def saveState(self, statepath: str) -> None: + try: + ss = [] + for k, v in self.bool_state.items(): + ss.append(f'{k} {v}\n') + for k, v in self.int_state.items(): + ss.append(f'{k} {v}\n') + ss.sort() + f = open(statepath, 'w') + f.write(f'# This state file was generated by {constants.APP_NAME} {constants.VERSION}.\n\n') # noqa: E501 + for s in ss: + f.write(s) + f.close() + except IOError: + # bad $HOME value? -- don't bother the user + utils.logDebug(f'Error writing {statepath}.') + + # select viewer for a newly selected file in the confirm close dialogue + def _confirmClose_row_activated_cb(self, tree, path, col, model): + self.notebook.set_current_page(self.notebook.page_num(model[path][3])) + + # toggle save state for a file listed in the confirm close dialogue + def _confirmClose_toggle_cb(self, cell, path, model): + model[path][0] = not model[path][0] + + # returns True if the list of viewers can be closed. The user will be + # given a chance to save any modified files before this method completes. + def confirmCloseViewers(self, viewers: List[FileDiffViewer]) -> bool: + # make a list of modified files + model = Gtk.ListStore.new([ + GObject.TYPE_BOOLEAN, + GObject.TYPE_STRING, + GObject.TYPE_INT, + GObject.TYPE_OBJECT]) + for v in viewers: + for f, h in enumerate(v.headers): + if h.has_edits: + model.append((True, v.title, f + 1, v)) + if len(model) == 0: + # there are no modified files, the viewers can be closed + return True + + # ask the user which files should be saved + dialog = Gtk.MessageDialog(transient_for=self.get_toplevel(), + destroy_with_parent=True, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.NONE, + text=_('Some files have unsaved changes. ' + 'Select the files to save before closing.')) + dialog.set_resizable(True) + dialog.set_title(constants.APP_NAME) + # add list of files with unsaved changes + sw = Gtk.ScrolledWindow() + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + treeview = Gtk.TreeView.new_with_model(model) + r = Gtk.CellRendererToggle() + r.connect('toggled', self._confirmClose_toggle_cb, model) + column = Gtk.TreeViewColumn(None, r) + column.add_attribute(r, 'active', 0) + treeview.append_column(column) + r = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(_('Tab'), r, text=1) + column.set_resizable(True) + column.set_expand(True) + column.set_sort_column_id(1) + treeview.append_column(column) + column = Gtk.TreeViewColumn(_('Pane'), r, text=2) + column.set_resizable(True) + column.set_sort_column_id(2) + treeview.append_column(column) + treeview.connect('row-activated', self._confirmClose_row_activated_cb, model) + sw.add(treeview) + treeview.show() + dialog.vbox.pack_start(sw, True, True, 0) + sw.show() + # add custom set of action buttons + dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) + button = Gtk.Button.new_with_mnemonic(_('Close _Without Saving')) + dialog.add_action_widget(button, Gtk.ResponseType.REJECT) + button.show() + dialog.add_button(Gtk.STOCK_SAVE, Gtk.ResponseType.OK) + dialog.set_default_response(Gtk.ResponseType.CANCEL) + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.OK: + # save all checked files + it = model.get_iter_first() + while it: + if model.get_value(it, 0): + f = model.get_value(it, 2) - 1 + v = model.get_value(it, 3) + if not v.save_file(f): + # cancel if we failed to save a file + return False + it = model.iter_next(it) + return True + # cancel if the user did not choose 'Close Without Saving' or 'Save' + return response == Gtk.ResponseType.REJECT + + # callback for the close button on each tab + def remove_tab_cb(self, widget, data): + nb = self.notebook + if nb.get_n_pages() > 1: + # warn about losing unsaved changes before removing a tab + if self.confirmCloseViewers([data]): + self.closed_tabs.append((nb.page_num(data), data, nb.get_tab_label(data))) + nb.remove(data) + nb.set_show_tabs(self.prefs.getBool('tabs_always_show') or nb.get_n_pages() > 1) + elif not self.prefs.getBool('tabs_warn_before_quit') or self._confirm_tab_close(): + self.quit_cb(widget, data) + + # convenience method to request confirmation when closing the last tab + def _confirm_tab_close(self) -> bool: + dialog = utils.MessageDialog( + self.get_toplevel(), + Gtk.MessageType.WARNING, + _('Closing this tab will quit %s.') % (constants.APP_NAME, ) + ) + end = (dialog.run() == Gtk.ResponseType.OK) + dialog.destroy() + return end + + # callback for RMB menu on notebook tabs + def notebooktab_pick_cb(self, widget, data): + self.notebook.set_current_page(data) + + # callback used when a mouse button is pressed on a notebook tab + def notebooktab_button_press_cb(self, widget, event, data): + if event.button == 2: + # remove the tab on MMB + self.remove_tab_cb(widget, data) + elif event.button == 3: + # create a popup to pick a tab for focus on RMB + menu = Gtk.Menu() + nb = self.notebook + for i in range(nb.get_n_pages()): + viewer = nb.get_nth_page(i) + item = Gtk.MenuItem.new_with_label(nb.get_tab_label(viewer).get_text()) + item.connect('activate', self.notebooktab_pick_cb, i) + menu.append(item) + item.show() + if viewer is data: + menu.select_item(item) + menu.popup(None, None, None, event.button, event.time) + + # update window's title + def updateTitle(self, viewer: FileDiffViewer) -> None: + title = self.notebook.get_tab_label(viewer).get_text() + self.set_title(f'{title} - {constants.APP_NAME}') + + # update the message in the status bar + def setStatus(self, s: Optional[str]) -> None: + sb = self.statusbar + context = sb.get_context_id('Message') + sb.pop(context) + if s is None: + s = '' + sb.push(context, s) + + # update the label in the status bar + def setSyntax(self, s): + # update menu + self.syntax_action.set_state(GLib.Variant.new_string(s or '')) + + # callback used when switching notebook pages + def switch_page_cb(self, widget, ptr, page_num): + viewer = widget.get_nth_page(page_num) + self.updateTitle(viewer) + self.setStatus(viewer.getStatus()) + self.setSyntax(viewer.getSyntax()) + + # callback used when a viewer's title changes + def title_changed_cb(self, widget, title): + # update the label in the notebook's tab + self.notebook.get_tab_label(widget).set_text(title) + if widget is self.getCurrentViewer(): + self.updateTitle(widget) + + # callback used when a viewer's status changes + def status_changed_cb(self, widget, s): + # update the label in the notebook's tab + if widget is self.getCurrentViewer(): + self.setStatus(s) + + # callback used when a viewer's syntax changes + def syntax_changed_cb(self, widget, s): + # update the label + if widget is self.getCurrentViewer(): + self.setSyntax(s) + + # create an empty viewer with 'n' panes + def newFileDiffViewer(self, n: int) -> FileDiffViewer: + self.viewer_count += 1 + tabname = _('File Merge %d') % (self.viewer_count, ) + tab = NotebookTab(tabname, Gtk.STOCK_FILE) + viewer = FileDiffViewer(n, self.prefs, tabname) + tab.button.connect('clicked', self.remove_tab_cb, viewer) + tab.connect('button-press-event', self.notebooktab_button_press_cb, viewer) + self.notebook.append_page(viewer, tab) + if hasattr(self.notebook, 'set_tab_reorderable'): + # some PyGTK packages incorrectly omit this method + self.notebook.set_tab_reorderable(viewer, True) + tab.show() + viewer.show() + self.notebook.set_show_tabs( + self.prefs.getBool('tabs_always_show') or self.notebook.get_n_pages() > 1 + ) + viewer.connect('title-changed', self.title_changed_cb) + viewer.connect('status-changed', self.status_changed_cb) + viewer.connect('syntax-changed', self.syntax_changed_cb) + return viewer + + # create a new viewer to display 'items' + def newLoadedFileDiffViewer(self, items): + specs = [] + if len(items) == 0: + for i in range(self.prefs.getInt('tabs_default_panes')): + specs.append(FileInfo()) + elif len(items) == 1 and len(items[0][1]) == 1: + # one file specified + # determine which other files to compare it with + name, data, label = items[0] + rev, encoding = data[0] + vcs = theVCSs.findByFilename(name, self.prefs) + if vcs is None: + # shift the existing file so it will be in the second pane + specs.append(FileInfo()) + specs.append(FileInfo(name, encoding, None, None, label)) + else: + if rev is None: + # no revision specified assume defaults + for name, rev in vcs.getFileTemplate(self.prefs, name): + if rev is None: + s = label + else: + s = None + specs.append(FileInfo(name, encoding, vcs, rev, s)) + else: + # single revision specified + specs.append(FileInfo(name, encoding, vcs, rev)) + specs.append(FileInfo(name, encoding, None, None, label)) + else: + # multiple files specified, use one pane for each file + for name, data, label in items: + for rev, encoding in data: + if rev is None: + vcs, s = None, label + else: + vcs, s = theVCSs.findByFilename(name, self.prefs), None + specs.append(FileInfo(name, encoding, vcs, rev, s)) + + # open a new viewer + viewer = self.newFileDiffViewer(max(2, len(specs))) + + # load the files + for i, spec in enumerate(specs): + viewer.load(i, spec) + return viewer + + def createSingleTab(self, items, labels, options): + """Create a new viewer for 'items'.""" + if len(items) > 0: + labelled_items = self._assign_file_labels(items, labels) + self.newLoadedFileDiffViewer(labelled_items).setOptions(options) + + def createSeparateTabs(self, items, labels, options): + """Create a new viewer for each item in 'items'.""" + # all tabs inherit the first tab's revision and encoding specifications + items = [(name, items[0][1]) for name, data in items] + for item in self._assign_file_labels(items, labels): + self.newLoadedFileDiffViewer([item]).setOptions(options) + + @staticmethod + def _assign_file_labels(items, labels): + """Assign user specified labels to the corresponding files.""" + new_items = [] + ss = labels[::-1] + for name, data in items: + if ss: + s = ss.pop() + else: + s = None + new_items.append((name, data, s)) + return new_items + + def createCommitFileTabs(self, items, labels, options): + """Create a new viewer for each modified file found in 'items'.""" + new_items = [] + for item in items: + name, data = item + # get full path to an existing ancestor directory + dn = os.path.abspath(name) + while not os.path.isdir(dn): + dn, old_dn = os.path.dirname(dn), dn + if dn == old_dn: + break + if len(new_items) == 0 or dn != new_items[-1][0]: + new_items.append([dn, None, []]) + dst = new_items[-1] + dst[1] = data[-1][1] + dst[2].append(name) + for dn, encoding, names in new_items: + vcs = theVCSs.findByFolder(dn, self.prefs) + if vcs is not None: + try: + for specs in vcs.getCommitTemplate(self.prefs, options['commit'], names): + viewer = self.newFileDiffViewer(len(specs)) + for i, spec in enumerate(specs): + name, rev = spec + viewer.load(i, FileInfo(name, encoding, vcs, rev)) + viewer.setOptions(options) + except (IOError, OSError): + utils.logErrorAndDialog( + _('Error retrieving commits for %s.') % (dn, ), + self.get_toplevel() + ) + + def createModifiedFileTabs(self, items, labels, options): + """Create a new viewer for each modified file found in 'items'.""" + new_items = [] + for item in items: + name, data = item + # get full path to an existing ancestor directory + dn = os.path.abspath(name) + while not os.path.isdir(dn): + dn, old_dn = os.path.dirname(dn), dn + if dn == old_dn: + break + if len(new_items) == 0 or dn != new_items[-1][0]: + new_items.append([dn, None, []]) + dst = new_items[-1] + dst[1] = data[-1][1] + dst[2].append(name) + for dn, encoding, names in new_items: + vcs = theVCSs.findByFolder(dn, self.prefs) + if vcs is not None: + try: + for specs in vcs.getFolderTemplate(self.prefs, names): + viewer = self.newFileDiffViewer(len(specs)) + for i, spec in enumerate(specs): + name, rev = spec + viewer.load(i, FileInfo(name, encoding, vcs, rev)) + viewer.setOptions(options) + except (IOError, OSError): + utils.logErrorAndDialog( + _('Error retrieving modifications for %s.') % (dn, ), + self.get_toplevel() + ) + + # close all tabs without differences + def closeOnSame(self) -> None: + for i in range(self.notebook.get_n_pages() - 1, -1, -1): + if not self.notebook.get_nth_page(i).hasDifferences(): + self.notebook.remove_page(i) + + # returns True if the application can safely quit + def confirmQuit(self) -> bool: + nb = self.notebook + return self.confirmCloseViewers([nb.get_nth_page(i) for i in range(nb.get_n_pages())]) + + # respond to close window request from the window manager + def delete_cb(self, widget, event): + if self.confirmQuit(): + self.get_application().quit() + return False + return True + + # returns the currently focused viewer + def getCurrentViewer(self) -> FileDiffViewer: + return self.notebook.get_nth_page(self.notebook.get_current_page()) + + # callback for the open file menu item + def open_file_cb(self, widget, data): + self.getCurrentViewer().open_file_cb(widget, data) + + # callback for the open file menu item + def open_file_in_new_tab_cb(self, widget, data): + dialog = FileChooserDialog( + _('Open File In New Tab'), + self.get_toplevel(), + self.prefs, + Gtk.FileChooserAction.OPEN, + Gtk.STOCK_OPEN, + True + ) + dialog.set_default_response(Gtk.ResponseType.OK) + accept = (dialog.run() == Gtk.ResponseType.OK) + name, encoding = dialog.get_filename(), dialog.get_encoding() + rev = dialog.get_revision().strip() + if rev == '': + rev = None + dialog.destroy() + if accept: + viewer = self.newLoadedFileDiffViewer([(name, [(rev, encoding)], None)]) + self.notebook.set_current_page(self.notebook.get_n_pages() - 1) + viewer.grab_focus() + + # callback for the open modified files menu item + def open_modified_files_cb(self, widget, data): + parent = self.get_toplevel() + dialog = FileChooserDialog( + _('Choose Folder With Modified Files'), + parent, + self.prefs, + Gtk.FileChooserAction.SELECT_FOLDER, + Gtk.STOCK_OPEN + ) + dialog.set_default_response(Gtk.ResponseType.OK) + accept = (dialog.run() == Gtk.ResponseType.OK) + name, encoding = dialog.get_filename(), dialog.get_encoding() + dialog.destroy() + if accept: + n = self.notebook.get_n_pages() + self.createModifiedFileTabs([(name, [(None, encoding)])], [], {}) + if self.notebook.get_n_pages() > n: + # we added some new tabs, focus on the first one + self.notebook.set_current_page(n) + self.getCurrentViewer().grab_focus() + else: + utils.logErrorAndDialog(_('No modified files found.'), parent) + + # callback for the open commit menu item + def open_commit_cb(self, widget, data): + parent = self.get_toplevel() + dialog = FileChooserDialog( + _('Choose Folder With Commit'), + parent, self.prefs, + Gtk.FileChooserAction.SELECT_FOLDER, + Gtk.STOCK_OPEN, + True + ) + dialog.set_default_response(Gtk.ResponseType.OK) + accept = (dialog.run() == Gtk.ResponseType.OK) + name = dialog.get_filename() + rev = dialog.get_revision() + encoding = dialog.get_encoding() + dialog.destroy() + if accept: + n = self.notebook.get_n_pages() + self.createCommitFileTabs([(name, [(None, encoding)])], [], {'commit': rev}) + if self.notebook.get_n_pages() > n: + # we added some new tabs, focus on the first one + self.notebook.set_current_page(n) + self.getCurrentViewer().grab_focus() + else: + utils.logErrorAndDialog(_('No committed files found.'), parent) + + # callback for the reload file menu item + def reload_file_cb(self, widget, data): + self.getCurrentViewer().reload_file_cb(widget, data) + + # callback for the save file menu item + def save_file_cb(self, widget, data): + self.getCurrentViewer().save_file_cb(widget, data) + + # callback for the save file as menu item + def save_file_as_cb(self, widget, data): + self.getCurrentViewer().save_file_as_cb(widget, data) + + # callback for the save all menu item + def save_all_cb(self, widget, data): + for i in range(self.notebook.get_n_pages()): + self.notebook.get_nth_page(i).save_all_cb(widget, data) + + # callback for the new 2-way file merge menu item + def new_2_way_file_merge_cb(self, widget, data): + viewer = self.newFileDiffViewer(2) + self.notebook.set_current_page(self.notebook.get_n_pages() - 1) + viewer.grab_focus() + + # callback for the new 3-way file merge menu item + def new_3_way_file_merge_cb(self, widget, data): + viewer = self.newFileDiffViewer(3) + self.notebook.set_current_page(self.notebook.get_n_pages() - 1) + viewer.grab_focus() + + # callback for the new n-way file merge menu item + def new_n_way_file_merge_cb(self, widget, data): + parent = self.get_toplevel() + dialog = NumericDialog( + parent, + _('New N-Way File Merge...'), + _('Number of panes: '), + val=4, + lower=2, + upper=16 + ) + okay = (dialog.run() == Gtk.ResponseType.ACCEPT) + npanes = dialog.get_value() + dialog.destroy() + if okay: + viewer = self.newFileDiffViewer(npanes) + self.notebook.set_current_page(self.notebook.get_n_pages() - 1) + viewer.grab_focus() + + # callback for the close tab menu item + def close_tab_cb(self, widget, data): + self.remove_tab_cb(widget, self.notebook.get_nth_page(self.notebook.get_current_page())) + + # callback for the undo close tab menu item + def undo_close_tab_cb(self, widget, data): + if len(self.closed_tabs) > 0: + i, tab, tab_label = self.closed_tabs.pop() + self.notebook.insert_page(tab, tab_label, i) + self.notebook.set_current_page(i) + self.notebook.set_show_tabs(True) + + # callback for the quit menu item + def quit_cb(self, widget, data): + if self.confirmQuit(): + self.get_application().quit() + + # request search parameters if force=True and then perform a search in the + # current viewer pane + def find(self, force: bool, reverse: bool) -> None: + viewer = self.getCurrentViewer() + if force or self.search_pattern is None: + # construct search dialog + history = self.search_history + pattern = viewer.getSelectedText() + for c in '\r\n': + i = pattern.find(c) + if i >= 0: + pattern = pattern[:i] + dialog = SearchDialog(self.get_toplevel(), pattern, history) + dialog.match_case_button.set_active(self.bool_state['search_matchcase']) + dialog.backwards_button.set_active(self.bool_state['search_backwards']) + keep = (dialog.run() == Gtk.ResponseType.ACCEPT) + # persist the search options + pattern = dialog.get_search_text() + match_case = dialog.match_case_button.get_active() + backwards = dialog.backwards_button.get_active() + dialog.destroy() + if not keep or pattern == '': + return + # perform the search + self.search_pattern = pattern + if pattern in history: + del history[history.index(pattern)] + history.insert(0, pattern) + self.bool_state['search_matchcase'] = match_case + self.bool_state['search_backwards'] = backwards + + # determine where to start searching from + reverse ^= self.bool_state['search_backwards'] + from_start, more = False, True + while more: + if viewer.find( + self.search_pattern, + self.bool_state['search_matchcase'], + reverse, + from_start + ): + break + + if reverse: + msg = _('Phrase not found. Continue from the end of the file?') + else: + msg = _('Phrase not found. Continue from the start of the file?') + dialog = utils.MessageDialog(self.get_toplevel(), Gtk.MessageType.QUESTION, msg) + dialog.set_default_response(Gtk.ResponseType.OK) + more = (dialog.run() == Gtk.ResponseType.OK) + dialog.destroy() + from_start = True + + # callback for the find menu item + def find_cb(self, widget, data): + self.find(True, False) + + # callback for the find next menu item + def find_next_cb(self, widget, data): + self.find(False, False) + + # callback for the find previous menu item + def find_previous_cb(self, widget, data): + self.find(False, True) + + # callback for the go to line menu item + def go_to_line_cb(self, widget, data): + self.getCurrentViewer().go_to_line_cb(widget, data) + + # notify all viewers of changes to the preferences + def preferences_updated(self) -> None: + n = self.notebook.get_n_pages() + self.notebook.set_show_tabs(self.prefs.getBool('tabs_always_show') or n > 1) + for i in range(n): + self.notebook.get_nth_page(i).prefsUpdated() + + # callback for the preferences menu item + def preferences_cb(self, widget, data): + if self.prefs.runDialog(self.get_toplevel()): + self.preferences_updated() + + # callback for all of the syntax highlighting menu items + def syntax_cb(self, widget, data): + # ignore events while we update the menu when switching tabs + # also ignore notification of the newly disabled item + if self.menu_update_depth == 0 and widget.get_active(): + self.getCurrentViewer().setSyntax(data) + + # callback for the first tab menu item + def first_tab_cb(self, widget, data): + self.notebook.set_current_page(0) + + # callback for the previous tab menu item + def previous_tab_cb(self, widget, data): + i, n = self.notebook.get_current_page(), self.notebook.get_n_pages() + self.notebook.set_current_page((n + i - 1) % n) + + # callback for the next tab menu item + def next_tab_cb(self, widget, data): + i, n = self.notebook.get_current_page(), self.notebook.get_n_pages() + self.notebook.set_current_page((i + 1) % n) + + # callback for the last tab menu item + def last_tab_cb(self, widget, data): + self.notebook.set_current_page(self.notebook.get_n_pages() - 1) + + # callback for most menu items and buttons + def button_cb(self, widget, data): + self.getCurrentViewer().button_cb(widget, data.get_string()) + + # display help documentation + def help_contents_cb(self, widget, data): + help_url = None + if utils.isWindows(): + # help documentation is distributed as local HTML files + # search for localized manual first + parts = ['manual'] + if utils.lang is not None: + parts = ['manual'] + parts.extend(utils.lang.split('_')) + while len(parts) > 0: + help_file = os.path.join(utils.bin_dir, '_'.join(parts) + '.html') + if os.path.isfile(help_file): + # we found a help file + help_url = self._path_to_url(help_file) + break + del parts[-1] + else: + # verify gnome-help is available + browser = None + p = os.environ.get('PATH', None) + if p is not None: + for s in p.split(os.pathsep): + fp = os.path.join(s, 'gnome-help') + if os.path.isfile(fp): + browser = fp + break + if browser is not None: + # find localized help file + if utils.lang is None: + parts = [] + else: + parts = utils.lang.split('_') + s = os.path.abspath(os.path.join(utils.bin_dir, '../share/gnome/help/diffuse')) + while True: + if len(parts) > 0: + d = '_'.join(parts) + else: + # fall back to using 'C' + d = 'C' + help_file = os.path.join(os.path.join(s, d), 'diffuse.xml') + if os.path.isfile(help_file): + args = [browser, self._path_to_url(help_file, 'ghelp')] + # spawnvp is not available on some systems, use spawnv instead + os.spawnv(os.P_NOWAIT, args[0], args) + return + if len(parts) == 0: + break + del parts[-1] + if help_url is None: + # no local help file is available, show on-line help + help_url = constants.WEBSITE + 'manual.html' + # ask for localized manual + if utils.lang is not None: + help_url += '?lang=' + utils.lang + # use a web browser to display the help documentation + webbrowser.open(help_url) + + @staticmethod + def _path_to_url(path: str, proto: str = 'file') -> str: + """Constructs a full URL for the named file.""" + r = [proto, ':///'] + s = os.path.abspath(path) + i = 0 + while i < len(s) and s[i] == os.sep: + i += 1 + for c in s[i:]: + if c == os.sep: + c = '/' + elif c == ':' and utils.isWindows(): + c = '|' + else: + v = ord(c) + if v <= 0x20 or v >= 0x7b or c in '$&+,/:;=?@"<>#%\\^[]`': + c = '%%%02X' % (v, ) + r.append(c) + return ''.join(r) + + # callback for the about menu item + def about_cb(self, widget, data): + dialog = AboutDialog() + dialog.run() + dialog.destroy() + + +def _append_buttons(box, size, specs): + """Convenience method for packing buttons into a container.""" + for spec in specs: + if len(spec) > 0: + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + button.set_can_focus(False) + image = Gtk.Image() + image.set_from_stock(spec[0], size) + button.add(image) + image.show() + if len(spec) > 2: + button.connect('clicked', spec[1], spec[2]) + if len(spec) > 3: + button.set_tooltip_text(spec[3]) + box.pack_start(button, False, False, 0) + button.show() + else: + separator = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) + box.pack_start(separator, False, False, 5) + separator.show() + + +GObject.signal_new('title-changed', FileDiffViewer, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, (str, )) # noqa: E501 +GObject.signal_new('status-changed', FileDiffViewer, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, (str, )) # noqa: E501 +GObject.signal_new('title-changed', PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) +GObject.signal_new('open', PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) +GObject.signal_new('reload', PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) +GObject.signal_new('save', PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ()) +GObject.signal_new('save-as', PaneHeader, GObject.SignalFlags.RUN_LAST, GObject.TYPE_NONE, ())