filediff.py 86.5 KB
Newer Older
1
# Copyright (C) 2002-2006 Stephen Kennedy <stevek@gnome.org>
2
# Copyright (C) 2009-2015 Kai Willadsen <kai.willadsen@gmail.com>
3
#
4 5 6 7 8 9 10 11 12 13 14 15
# 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, see <http://www.gnu.org/licenses/>.
steve9000's avatar
steve9000 committed
16

17
import copy
18
import functools
19
import math
steve9000's avatar
steve9000 committed
20

21 22
from gi.repository import Gdk
from gi.repository import Gio
23 24 25
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Gtk
26
from gi.repository import GtkSource
27

28 29
# TODO: Don't from-import whole modules
from meld import misc
30
from meld.conf import _, ui_file
31
from meld.const import MODE_DELETE, MODE_INSERT, MODE_REPLACE, NEWLINES
32
from meld.iohelpers import prompt_save_filename
33
from meld.matchers.diffutil import Differ, merged_chunk_order
34
from meld.matchers.helpers import CachedSequenceMatcher
35
from meld.matchers.merge import Merger
36 37
from meld.meldbuffer import (
    BufferDeletionAction, BufferInsertionAction, BufferLines)
38
from meld.melddoc import ComparisonState, MeldDoc
39
from meld.misc import user_critical, with_focused_pane
40
from meld.patchdialog import PatchDialog
41
from meld.recent import RecentType
42
from meld.settings import bind_settings, meldsettings
43
from meld.sourceview import (
44
    get_custom_encoding_candidates, LanguageManager, TextviewLineAnimationType)
45
from meld.ui._gtktemplate import Template
46
from meld.ui.findbar import FindBar
47 48
from meld.ui.util import (
    make_multiobject_property_action, map_widgets_into_lists)
49
from meld.undo import UndoSequence
50

51

52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
def with_scroll_lock(lock_attr):
    """Decorator for locking a callback based on an instance attribute

    This is used when scrolling panes. Since a scroll event in one pane
    causes us to set the scroll position in other panes, we need to
    stop these other panes re-scrolling the initial one.

    Unlike a threading-style lock, this decorator discards any calls
    that occur while the lock is held, rather than queuing them.

    :param lock_attr: The instance attribute used to lock access
    """
    def wrap(function):
        @functools.wraps(function)
        def wrap_function(locked, *args, **kwargs):
            if getattr(locked, lock_attr, False) or locked._scroll_lock:
                return

            try:
                setattr(locked, lock_attr, True)
                return function(locked, *args, **kwargs)
            finally:
                setattr(locked, lock_attr, False)
        return wrap_function
    return wrap


79
MASK_SHIFT, MASK_CTRL = 1, 2
Kai Willadsen's avatar
Kai Willadsen committed
80
PANE_LEFT, PANE_RIGHT = -1, +1
81

82

83
class CursorDetails:
84 85 86 87
    __slots__ = (
        "pane", "pos", "line", "offset", "chunk", "prev", "next",
        "prev_conflict", "next_conflict",
    )
88 89 90 91 92 93

    def __init__(self):
        for var in self.__slots__:
            setattr(self, var, None)


94 95
@Template(resource_path='/org/gnome/meld/ui/filediff.ui')
class FileDiff(Gtk.VBox, MeldDoc):
96 97 98 99
    """Two or three way comparison of text files"""

    __gtype_name__ = "FileDiff"

100 101 102 103 104 105 106
    close_signal = MeldDoc.close_signal
    create_diff_signal = MeldDoc.create_diff_signal
    file_changed_signal = MeldDoc.file_changed_signal
    label_changed = MeldDoc.label_changed
    next_diff_changed_signal = MeldDoc.next_diff_changed_signal
    tab_state_changed = MeldDoc.tab_state_changed

107 108 109 110
    __gsettings_bindings__ = (
        ('ignore-blank-lines', 'ignore-blank-lines'),
    )

111
    ignore_blank_lines = GObject.Property(
112 113 114 115 116
        type=bool,
        nick="Ignore blank lines",
        blurb="Whether to ignore blank lines when comparing file contents",
        default=False,
    )
117

118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164
    actiongroup = Template.Child('FilediffActions')
    diffmap0 = Template.Child()
    diffmap1 = Template.Child()
    dummy_toolbar_diffmap0 = Template.Child()
    dummy_toolbar_diffmap1 = Template.Child()
    dummy_toolbar_linkmap0 = Template.Child()
    dummy_toolbar_linkmap1 = Template.Child()
    fileentry0 = Template.Child()
    fileentry1 = Template.Child()
    fileentry2 = Template.Child()
    fileentry_toolitem0 = Template.Child()
    fileentry_toolitem1 = Template.Child()
    fileentry_toolitem2 = Template.Child()
    file_save_button0 = Template.Child()
    file_save_button1 = Template.Child()
    file_save_button2 = Template.Child()
    file_toolbar0 = Template.Child()
    file_toolbar1 = Template.Child()
    file_toolbar2 = Template.Child()
    filelabel0 = Template.Child()
    filelabel1 = Template.Child()
    filelabel2 = Template.Child()
    filelabel_toolitem0 = Template.Child()
    filelabel_toolitem1 = Template.Child()
    filelabel_toolitem2 = Template.Child()
    grid = Template.Child()
    msgarea_mgr0 = Template.Child()
    msgarea_mgr1 = Template.Child()
    msgarea_mgr2 = Template.Child()
    readonlytoggle0 = Template.Child()
    readonlytoggle1 = Template.Child()
    readonlytoggle2 = Template.Child()
    scrolledwindow0 = Template.Child()
    scrolledwindow1 = Template.Child()
    scrolledwindow2 = Template.Child()
    statusbar0 = Template.Child()
    statusbar1 = Template.Child()
    statusbar2 = Template.Child()
    linkmap0 = Template.Child()
    linkmap1 = Template.Child()
    textview0 = Template.Child()
    textview1 = Template.Child()
    textview2 = Template.Child()
    vbox0 = Template.Child()
    vbox1 = Template.Child()
    vbox2 = Template.Child()

165
    differ = Differ
166

167 168 169 170 171 172
    keylookup = {
        Gdk.KEY_Shift_L: MASK_SHIFT,
        Gdk.KEY_Shift_R: MASK_SHIFT,
        Gdk.KEY_Control_L: MASK_CTRL,
        Gdk.KEY_Control_R: MASK_CTRL,
    }
Stephen Kennedy's avatar
Stephen Kennedy committed
173

174
    # Identifiers for MsgArea messages
175
    (MSG_SAME, MSG_SLOW_HIGHLIGHT, MSG_SYNCPOINTS) = list(range(3))
176 177 178
    # Transient messages that should be removed if any file in the
    # comparison gets reloaded.
    TRANSIENT_MESSAGES = {MSG_SAME, MSG_SLOW_HIGHLIGHT}
179

180
    __gsignals__ = {
181 182 183 184
        'next-conflict-changed': (
            GObject.SignalFlags.RUN_FIRST, None, (bool, bool)),
        'action-mode-changed': (
            GObject.SignalFlags.RUN_FIRST, None, (int,)),
185 186
    }

187
    def __init__(self, num_panes):
188 189 190 191 192 193 194 195 196
        super().__init__()
        # FIXME:
        # This unimaginable hack exists because GObject (or GTK+?)
        # doesn't actually correctly chain init calls, even if they're
        # not to GObjects. As a workaround, we *should* just be able to
        # put our class first, but because of Gtk.Template we can't do
        # that if it's a GObject, because GObject doesn't support
        # multiple inheritance and we need to inherit from our Widget
        # parent to make Template work.
197
        MeldDoc.__init__(self)
198
        self.init_template()
199 200
        bind_settings(self)

201 202
        widget_lists = [
            "diffmap", "file_save_button", "file_toolbar", "fileentry",
203
            "linkmap", "msgarea_mgr", "readonlytoggle",
204
            "scrolledwindow", "textview", "vbox",
205
            "dummy_toolbar_linkmap", "filelabel_toolitem", "filelabel",
206
            "fileentry_toolitem", "dummy_toolbar_diffmap", "statusbar",
207
        ]
208
        map_widgets_into_lists(self, widget_lists)
209

210
        self.warned_bad_comparison = False
211
        self._keymask = 0
212
        self.meta = {}
213
        self.lines_removed = 0
214
        self.focus_pane = None
215
        self.textbuffer = [v.get_buffer() for v in self.textview]
216
        self.buffer_texts = [BufferLines(b) for b in self.textbuffer]
217
        self.undosequence = UndoSequence(self.textbuffer)
218 219
        self.text_filters = []
        self.create_text_filters()
220
        self.settings_handlers = [
221 222
            meldsettings.connect(
                "text-filters-changed", self.on_text_filters_changed)
223
        ]
224 225 226
        self.buffer_filtered = [
            BufferLines(b, self._filter_text) for b in self.textbuffer
        ]
227 228 229
        for (i, w) in enumerate(self.scrolledwindow):
            w.get_vadjustment().connect("value-changed", self._sync_vscroll, i)
            w.get_hadjustment().connect("value-changed", self._sync_hscroll)
230
        self._connect_buffer_handlers()
231 232
        self._sync_vscroll_lock = False
        self._sync_hscroll_lock = False
233
        self._scroll_lock = False
234
        self.linediffer = self.differ()
235
        self.force_highlight = False
236
        self.syncpoints = []
237
        self.in_nested_textview_gutter_expose = False
238
        self._cached_match = CachedSequenceMatcher(self.scheduler)
239

240 241
        # Set up property actions for statusbar toggles
        sourceview_prop_actions = [
242
            'draw-spaces-bool',
243 244 245 246 247 248 249 250 251 252 253
            'highlight-current-line-local',
            'show-line-numbers',
            'wrap-mode-bool',
        ]

        prop_action_group = Gio.SimpleActionGroup()
        for prop in sourceview_prop_actions:
            action = make_multiobject_property_action(self.textview, prop)
            prop_action_group.add_action(action)
        self.insert_action_group('view', prop_action_group)

254
        for buf in self.textbuffer:
255
            buf.undo_sequence = self.undosequence
256 257
            buf.connect("notify::has-selection",
                        self.update_text_actions_sensitivity)
258
            buf.data.file_changed_signal.connect(self.notify_file_changed)
259

260
        self.ui_file = ui_file("filediff-ui.xml")
261
        self.actiongroup.set_translation_domain("meld")
262

263 264 265 266 267
        # Alternate keybindings for a few commands.
        self.extra_accels = (
            ("<Alt>KP_Delete", self.delete_change),
        )

268
        self.findbar = FindBar(self.grid)
269
        self.grid.attach(self.findbar, 1, 2, 5, 1)
270

271
        self.set_num_panes(num_panes)
272
        self.cursor = CursorDetails()
273 274 275
        for t in self.textview:
            t.connect("focus-in-event", self.on_current_diff_changed)
            t.connect("focus-out-event", self.on_current_diff_changed)
276 277
            t.connect(
                "drag_data_received", self.on_textview_drag_data_received)
278 279 280 281 282 283 284 285

        # Bind all overwrite properties together, so that toggling
        # overwrite mode is per-FileDiff.
        for t in self.textview[1:]:
            t.bind_property(
                'overwrite', self.textview[0], 'overwrite',
                GObject.BindingFlags.BIDIRECTIONAL)

286
        self.linediffer.connect("diffs-changed", self.on_diffs_changed)
287
        self.undosequence.connect("checkpointed", self.on_undo_checkpointed)
288
        self.connect("next-conflict-changed", self.on_next_conflict_changed)
Stephen Kennedy's avatar
Stephen Kennedy committed
289

290 291 292
        for diffmap in self.diffmap:
            self.linediffer.connect('diffs-changed', diffmap.on_diffs_changed)

293
        for statusbar, buf in zip(self.statusbar, self.textbuffer):
294 295 296 297
            buf.bind_property(
                'language', statusbar, 'source-language',
                GObject.BindingFlags.BIDIRECTIONAL)

298 299 300 301
            buf.data.bind_property(
                'encoding', statusbar, 'source-encoding',
                GObject.BindingFlags.DEFAULT)

302
            def reload_with_encoding(widget, encoding, pane):
303 304
                buffer = self.textbuffer[pane]
                if not self.check_unsaved_changes([buffer]):
305
                    return
306
                self.set_file(pane, buffer.data.gfile, encoding)
307

308
            def go_to_line(widget, line, pane):
309
                self.move_cursor(pane, line, focus=False)
310

311 312
            pane = self.statusbar.index(statusbar)
            statusbar.connect('encoding-changed', reload_with_encoding, pane)
313
            statusbar.connect('go-to-line', go_to_line, pane)
314

315 316
        # Prototype implementation

317 318
        from meld.gutterrendererchunk import (
            GutterRendererChunkAction, GutterRendererChunkLines)
319 320 321

        for pane, t in enumerate(self.textview):
            # FIXME: set_num_panes will break this good
322 323
            direction = t.get_direction()

324 325
            if pane == 0 or (pane == 1 and self.num_panes == 3):
                window = Gtk.TextWindowType.RIGHT
326 327
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.LEFT
328
                views = [self.textview[pane], self.textview[pane + 1]]
329 330
                renderer = GutterRendererChunkAction(
                    pane, pane + 1, views, self, self.linediffer)
331 332 333 334
                gutter = t.get_gutter(window)
                gutter.insert(renderer, 10)
            if pane in (1, 2):
                window = Gtk.TextWindowType.LEFT
335 336
                if direction == Gtk.TextDirection.RTL:
                    window = Gtk.TextWindowType.RIGHT
337
                views = [self.textview[pane], self.textview[pane - 1]]
338 339
                renderer = GutterRendererChunkAction(
                    pane, pane - 1, views, self, self.linediffer)
340
                gutter = t.get_gutter(window)
341
                gutter.insert(renderer, -40)
342

343 344 345
            # TODO: This renderer handling should all be part of
            # MeldSourceView, but our current diff-chunk-handling makes
            # this difficult.
346 347 348
            window = Gtk.TextWindowType.LEFT
            if direction == Gtk.TextDirection.RTL:
                window = Gtk.TextWindowType.RIGHT
349 350
            renderer = GutterRendererChunkLines(
                pane, pane - 1, self.linediffer)
351 352
            gutter = t.get_gutter(window)
            gutter.insert(renderer, -30)
353
            t.line_renderer = renderer
354

355
        self.connect("notify::ignore-blank-lines", self.refresh_comparison)
356

357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372
    def on_container_switch_in_event(self, ui):
        MeldDoc.on_container_switch_in_event(self, ui)

        accel_group = ui.get_accel_group()
        for accel, callback in self.extra_accels:
            keyval, mask = Gtk.accelerator_parse(accel)
            accel_group.connect(keyval, mask, 0, callback)

    def on_container_switch_out_event(self, ui):
        accel_group = ui.get_accel_group()
        for accel, callback in self.extra_accels:
            keyval, mask = Gtk.accelerator_parse(accel)
            accel_group.disconnect_key(keyval, mask)

        MeldDoc.on_container_switch_out_event(self, ui)

373 374
    def get_keymask(self):
        return self._keymask
375

376 377 378 379 380 381 382 383 384 385 386
    def set_keymask(self, value):
        if value & MASK_SHIFT:
            mode = MODE_DELETE
        elif value & MASK_CTRL:
            mode = MODE_INSERT
        else:
            mode = MODE_REPLACE
        self._keymask = value
        self.emit("action-mode-changed", mode)
    keymask = property(get_keymask, set_keymask)

387
    @Template.Callback()
388 389 390 391 392 393 394 395 396 397
    def on_key_event(self, object, event):
        keymap = Gdk.Keymap.get_default()
        ok, keyval, group, lvl, consumed = keymap.translate_keyboard_state(
            event.hardware_keycode, 0, event.group)
        mod_key = self.keylookup.get(keyval, 0)
        if event.type == Gdk.EventType.KEY_PRESS:
            self.keymask |= mod_key
            if event.keyval == Gdk.KEY_Escape:
                self.findbar.hide()
        elif event.type == Gdk.EventType.KEY_RELEASE:
398 399
            if event.keyval == Gdk.KEY_Return and self.keymask & MASK_SHIFT:
                self.findbar.start_find_previous(self.focus_pane)
400 401
            self.keymask &= ~mod_key

402 403 404
    def on_text_filters_changed(self, app):
        relevant_change = self.create_text_filters()
        if relevant_change:
405
            self.refresh_comparison()
406 407 408 409

    def create_text_filters(self):
        # In contrast to file filters, ordering of text filters can matter
        old_active = [f.filter_string for f in self.text_filters if f.active]
410 411 412
        new_active = [
            f.filter_string for f in meldsettings.text_filters if f.active
        ]
413 414
        active_filters_changed = old_active != new_active

415
        self.text_filters = [copy.copy(f) for f in meldsettings.text_filters]
416 417 418

        return active_filters_changed

419 420
    def _disconnect_buffer_handlers(self):
        for textview in self.textview:
421
            textview.set_sensitive(False)
422
        for buf in self.textbuffer:
423 424
            for h in buf.handlers:
                buf.disconnect(h)
425
            buf.handlers = []
426 427

    def _connect_buffer_handlers(self):
428 429
        for textview in self.textview:
            textview.set_sensitive(True)
430
        for buf in self.textbuffer:
431 432 433
            id0 = buf.connect("insert-text", self.on_text_insert_text)
            id1 = buf.connect("delete-range", self.on_text_delete_range)
            id2 = buf.connect_after("insert-text", self.after_text_insert_text)
434 435 436 437
            id3 = buf.connect_after(
                "delete-range", self.after_text_delete_range)
            id4 = buf.connect(
                "notify::cursor-position", self.on_cursor_position_changed)
438
            buf.handlers = id0, id1, id2, id3, id4
439

440 441 442 443 444 445 446 447
    def on_cursor_position_changed(self, buf, pspec, force=False):
        pane = self.textbuffer.index(buf)
        pos = buf.props.cursor_position
        if pane == self.cursor.pane and pos == self.cursor.pos and not force:
            return
        self.cursor.pane, self.cursor.pos = pane, pos

        cursor_it = buf.get_iter_at_offset(pos)
448
        offset = self.textview[pane].get_visual_column(cursor_it)
449 450
        line = cursor_it.get_line()

451
        self.statusbar[pane].props.cursor_position = (line, offset)
452 453

        if line != self.cursor.line or force:
Kai Willadsen's avatar
Kai Willadsen committed
454
            chunk, prev, next_ = self.linediffer.locate_chunk(pane, line)
455
            if chunk != self.cursor.chunk or force:
456
                self.cursor.chunk = chunk
457
                self.on_current_diff_changed()
Kai Willadsen's avatar
Kai Willadsen committed
458
            if prev != self.cursor.prev or next_ != self.cursor.next or force:
459 460
                self.next_diff_changed_signal.emit(
                    prev is not None, next_ is not None)
461 462 463 464 465

            prev_conflict, next_conflict = None, None
            for conflict in self.linediffer.conflicts:
                if prev is not None and conflict <= prev:
                    prev_conflict = conflict
Kai Willadsen's avatar
Kai Willadsen committed
466
                if next_ is not None and conflict >= next_:
467 468 469
                    next_conflict = conflict
                    break
            if prev_conflict != self.cursor.prev_conflict or \
470
               next_conflict != self.cursor.next_conflict or force:
471 472 473
                self.emit("next-conflict-changed", prev_conflict is not None,
                          next_conflict is not None)

Kai Willadsen's avatar
Kai Willadsen committed
474
            self.cursor.prev, self.cursor.next = prev, next_
475 476
            self.cursor.prev_conflict = prev_conflict
            self.cursor.next_conflict = next_conflict
477
        self.cursor.line, self.cursor.offset = line, offset
478

479
    def on_current_diff_changed(self, *args):
480 481 482 483 484 485 486
        pane = self._get_focused_pane()
        if pane != -1:
            # While this *should* be redundant, it's possible for focus pane
            # and cursor pane to be different in several situations.
            pane = self.cursor.pane
            chunk_id = self.cursor.chunk

487
        if pane == -1 or chunk_id is None:
488 489
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (False,) * 7
490
        else:
491 492 493
            push_left, push_right, pull_left, pull_right, delete, \
                copy_left, copy_right = (True,) * 7

494 495 496 497 498 499
            # Push and Delete are active if the current pane has something to
            # act on, and the target pane exists and is editable. Pull is
            # sensitive if the source pane has something to get, and the
            # current pane is editable. Copy actions are sensitive if the
            # conditions for push are met, *and* there is some content in the
            # target pane.
500 501
            editable = self.textview[pane].get_editable()
            editable_left = pane > 0 and self.textview[pane - 1].get_editable()
502 503 504 505
            editable_right = (
                pane < self.num_panes - 1 and
                self.textview[pane + 1].get_editable()
            )
506 507
            if pane == 0 or pane == 2:
                chunk = self.linediffer.get_chunk(chunk_id, pane)
508 509
                is_insert = chunk[1] == chunk[2]
                is_delete = chunk[3] == chunk[4]
510 511
                push_left = editable_left
                push_right = editable_right
512 513 514 515 516
                pull_left = pane == 2 and editable and not is_delete
                pull_right = pane == 0 and editable and not is_delete
                delete = editable and not is_insert
                copy_left = editable_left and not is_insert or is_delete
                copy_right = editable_right and not is_insert or is_delete
517
            elif pane == 1:
518
                chunk0 = self.linediffer.get_chunk(chunk_id, 1, 0)
519 520
                chunk2 = None
                if self.num_panes == 3:
521
                    chunk2 = self.linediffer.get_chunk(chunk_id, 1, 2)
522 523 524 525
                left_mid_exists = bool(chunk0 and chunk0[1] != chunk0[2])
                left_exists = bool(chunk0 and chunk0[3] != chunk0[4])
                right_mid_exists = bool(chunk2 and chunk2[1] != chunk2[2])
                right_exists = bool(chunk2 and chunk2[3] != chunk2[4])
526 527
                push_left = editable_left
                push_right = editable_right
528 529 530
                pull_left = editable and left_exists
                pull_right = editable and right_exists
                delete = editable and (left_mid_exists or right_mid_exists)
531
                copy_left = editable_left and left_mid_exists and left_exists
532 533
                copy_right = (
                    editable_right and right_mid_exists and right_exists)
534 535 536 537 538
        self.actiongroup.get_action("PushLeft").set_sensitive(push_left)
        self.actiongroup.get_action("PushRight").set_sensitive(push_right)
        self.actiongroup.get_action("PullLeft").set_sensitive(pull_left)
        self.actiongroup.get_action("PullRight").set_sensitive(pull_right)
        self.actiongroup.get_action("Delete").set_sensitive(delete)
539 540 541 542
        self.actiongroup.get_action("CopyLeftUp").set_sensitive(copy_left)
        self.actiongroup.get_action("CopyLeftDown").set_sensitive(copy_left)
        self.actiongroup.get_action("CopyRightUp").set_sensitive(copy_right)
        self.actiongroup.get_action("CopyRightDown").set_sensitive(copy_right)
543 544 545 546 547

        prev_pane = pane > 0
        next_pane = pane < self.num_panes - 1
        self.actiongroup.get_action("PrevPane").set_sensitive(prev_pane)
        self.actiongroup.get_action("NextPane").set_sensitive(next_pane)
548 549
        # FIXME: don't queue_draw() on everything... just on what changed
        self.queue_draw()
550

551 552 553 554
    def on_next_conflict_changed(self, doc, have_prev, have_next):
        self.actiongroup.get_action("PrevConflict").set_sensitive(have_prev)
        self.actiongroup.get_action("NextConflict").set_sensitive(have_next)

555 556 557 558 559 560 561 562 563 564
    def scroll_to_chunk_index(self, chunk_index, tolerance):
        """Scrolls chunks with the given index on screen in all panes"""
        starts = self.linediffer.get_chunk_starts(chunk_index)
        for pane, start in enumerate(starts):
            if start is None:
                continue
            buf = self.textbuffer[pane]
            it = buf.get_iter_at_line(start)
            self.textview[pane].scroll_to_iter(it, tolerance, True, 0.5, 0.5)

565
    def go_to_chunk(self, target, pane=None, centered=False):
566 567 568
        if target is None:
            return

569 570 571
        if pane is None:
            pane = self._get_focused_pane()
            if pane == -1:
572
                pane = 1 if self.num_panes > 1 else 0
573 574 575 576 577 578 579 580 581 582

        chunk = self.linediffer.get_chunk(target, pane)
        if not chunk:
            return

        # Warp the cursor to the first line of the chunk
        buf = self.textbuffer[pane]
        if self.cursor.line != chunk[1]:
            buf.place_cursor(buf.get_iter_at_line(chunk[1]))

583 584
        # Scroll all panes to the given chunk, and then ensure that the newly
        # placed cursor is definitely on-screen.
585
        tolerance = 0.0 if centered else 0.2
586
        self.scroll_to_chunk_index(target, tolerance)
587 588 589
        self.textview[pane].scroll_to_mark(
            buf.get_insert(), tolerance, True, 0.5, 0.5)

590 591 592 593 594 595 596 597 598 599
        # If we've moved to a valid chunk (or stayed in the first/last chunk)
        # then briefly highlight the chunk for better visual orientation.
        chunk_start = buf.get_iter_at_line_or_eof(chunk[1])
        chunk_end = buf.get_iter_at_line_or_eof(chunk[2])
        mark0 = buf.create_mark(None, chunk_start, True)
        mark1 = buf.create_mark(None, chunk_end, True)
        self.textview[pane].add_fading_highlight(
            mark0, mark1, 'focus-highlight', 400000, starting_alpha=0.3,
            anim_type=TextviewLineAnimationType.stroke)

600
    @Template.Callback()
601 602 603
    def on_linkmap_scroll_event(self, linkmap, event):
        self.next_diff(event.direction)

604 605 606
    def next_diff(self, direction, centered=False):
        target = (self.cursor.next if direction == Gdk.ScrollDirection.DOWN
                  else self.cursor.prev)
607
        self.go_to_chunk(target, centered=centered)
608

609
    @Template.Callback()
610 611 612
    def action_previous_conflict(self, *args):
        self.go_to_chunk(self.cursor.prev_conflict, self.cursor.pane)

613
    @Template.Callback()
614 615 616 617 618 619 620 621
    def action_next_conflict(self, *args):
        self.go_to_chunk(self.cursor.next_conflict, self.cursor.pane)

    def action_previous_diff(self, *args):
        self.go_to_chunk(self.cursor.prev)

    def action_next_diff(self, *args):
        self.go_to_chunk(self.cursor.next)
622

623 624 625 626 627
    def get_action_chunk(self, src, dst):
        valid_panes = list(range(0, self.num_panes))
        if (src not in valid_panes or dst not in valid_panes or
                self.cursor.chunk is None):
            raise ValueError("Action was taken on invalid panes")
628 629

        chunk = self.linediffer.get_chunk(self.cursor.chunk, src, dst)
630 631 632
        if chunk is None:
            raise ValueError("Action was taken on a missing chunk")
        return chunk
633

634
    def get_action_panes(self, direction, reverse=False):
635 636
        src = self._get_focused_pane()
        dst = src + direction
637 638
        return (dst, src) if reverse else (src, dst)

639
    @Template.Callback()
640
    def action_push_change_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
641
        src, dst = self.get_action_panes(PANE_LEFT)
642 643
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

644
    @Template.Callback()
645
    def action_push_change_right(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
646
        src, dst = self.get_action_panes(PANE_RIGHT)
647 648
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

649
    @Template.Callback()
650
    def action_pull_change_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
651
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
652 653
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

654
    @Template.Callback()
655
    def action_pull_change_right(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
656
        src, dst = self.get_action_panes(PANE_RIGHT, reverse=True)
657 658
        self.replace_chunk(src, dst, self.get_action_chunk(src, dst))

659
    @Template.Callback()
660
    def action_copy_change_left_up(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
661
        src, dst = self.get_action_panes(PANE_LEFT)
662 663 664
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=True)

665
    @Template.Callback()
666
    def action_copy_change_right_up(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
667
        src, dst = self.get_action_panes(PANE_RIGHT)
668 669 670
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=True)

671
    @Template.Callback()
672
    def action_copy_change_left_down(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
673
        src, dst = self.get_action_panes(PANE_LEFT)
674 675 676
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=False)

677
    @Template.Callback()
678
    def action_copy_change_right_down(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
679
        src, dst = self.get_action_panes(PANE_RIGHT)
680 681
        self.copy_chunk(
            src, dst, self.get_action_chunk(src, dst), copy_up=False)
682

683
    def pull_all_non_conflicting_changes(self, src, dst):
684
        merger = Merger()
685
        merger.differ = self.linediffer
686
        merger.texts = self.buffer_texts
687 688
        for mergedfile in merger.merge_2_files(src, dst):
            pass
689
        self._sync_vscroll_lock = True
690
        self.textbuffer[dst].begin_user_action()
691
        self.textbuffer[dst].set_text(mergedfile)
692
        self.textbuffer[dst].end_user_action()
693

694 695 696 697
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[src].get_vadjustment(), src)
        self.scheduler.add_task(resync)
698

699
    @Template.Callback()
700
    def action_pull_all_changes_left(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
701
        src, dst = self.get_action_panes(PANE_LEFT, reverse=True)
702 703
        self.pull_all_non_conflicting_changes(src, dst)

704
    @Template.Callback()
705
    def action_pull_all_changes_right(self, *args):
Kai Willadsen's avatar
Kai Willadsen committed
706
        src, dst = self.get_action_panes(PANE_RIGHT, reverse=True)
707 708
        self.pull_all_non_conflicting_changes(src, dst)

709
    @Template.Callback()
710
    def merge_all_non_conflicting_changes(self, *args):
711
        dst = 1
712
        merger = Merger()
713
        merger.differ = self.linediffer
714
        merger.texts = self.buffer_texts
715 716
        for mergedfile in merger.merge_3_files(False):
            pass
717
        self._sync_vscroll_lock = True
718
        self.textbuffer[dst].begin_user_action()
719
        self.textbuffer[dst].set_text(mergedfile)
720
        self.textbuffer[dst].end_user_action()
721

722 723 724 725
        def resync():
            self._sync_vscroll_lock = False
            self._sync_vscroll(self.scrolledwindow[0].get_vadjustment(), 0)
        self.scheduler.add_task(resync)
726

727
    @Template.Callback()
728
    @with_focused_pane
729
    def delete_change(self, pane, *args):
730
        chunk = self.linediffer.get_chunk(self.cursor.chunk, pane)
731
        assert(self.cursor.chunk is not None)
732 733 734
        assert(chunk is not None)
        self.delete_chunk(pane, chunk)

735 736 737 738 739 740 741 742 743 744 745 746 747 748 749
    def _synth_chunk(self, pane0, pane1, line):
        """Returns the Same chunk that would exist at
           the given location if we didn't remove Same chunks"""

        # This method is a hack around our existing diffutil data structures;
        # getting rid of the Same chunk removal is difficult, as several places
        # have baked in the assumption of only being given changed blocks.

        buf0, buf1 = self.textbuffer[pane0], self.textbuffer[pane1]
        start0, end0 = 0, buf0.get_line_count() - 1
        start1, end1 = 0, buf1.get_line_count() - 1

        # This hack is required when pane0's prev/next chunk doesn't exist
        # (i.e., is Same) between pane0 and pane1.
        prev_chunk0, prev_chunk1, next_chunk0, next_chunk1 = (None,) * 4
Kai Willadsen's avatar
Kai Willadsen committed
750
        _, prev, next_ = self.linediffer.locate_chunk(pane0, line)
751 752 753 754 755 756 757 758 759 760
        if prev is not None:
            while prev >= 0:
                prev_chunk0 = self.linediffer.get_chunk(prev, pane0, pane1)
                prev_chunk1 = self.linediffer.get_chunk(prev, pane1, pane0)
                if None not in (prev_chunk0, prev_chunk1):
                    start0 = prev_chunk0[2]
                    start1 = prev_chunk1[2]
                    break
                prev -= 1

Kai Willadsen's avatar
Kai Willadsen committed
761 762 763 764
        if next_ is not None:
            while next_ < self.linediffer.diff_count():
                next_chunk0 = self.linediffer.get_chunk(next_, pane0, pane1)
                next_chunk1 = self.linediffer.get_chunk(next_, pane1, pane0)
765 766 767 768
                if None not in (next_chunk0, next_chunk1):
                    end0 = next_chunk0[1]
                    end1 = next_chunk1[1]
                    break
Kai Willadsen's avatar
Kai Willadsen committed
769
                next_ += 1
770

771 772
        # TODO: Move myers.DiffChunk to a more general place, update
        # this to use it, and update callers to use nice attributes.
773 774 775 776 777
        return "Same", start0, end0, start1, end1

    def _corresponding_chunk_line(self, chunk, line, pane, new_pane):
        """Approximates the corresponding line between panes"""

778
        new_buf = self.textbuffer[new_pane]
779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800

        # Special-case cross-pane jumps
        if (pane == 0 and new_pane == 2) or (pane == 2 and new_pane == 0):
            proxy = self._corresponding_chunk_line(chunk, line, pane, 1)
            return self._corresponding_chunk_line(chunk, proxy, 1, new_pane)

        # Either we are currently in a identifiable chunk, or we are in a Same
        # chunk; if we establish the start/end of that chunk in both panes, we
        # can figure out what our new offset should be.
        cur_chunk = None
        if chunk is not None:
            cur_chunk = self.linediffer.get_chunk(chunk, pane, new_pane)

        if cur_chunk is None:
            cur_chunk = self._synth_chunk(pane, new_pane, line)
        cur_start, cur_end, new_start, new_end = cur_chunk[1:5]

        # If the new buffer's current cursor is already in the correct chunk,
        # assume that we have in-progress editing, and don't move it.
        cursor_it = new_buf.get_iter_at_mark(new_buf.get_insert())
        cursor_line = cursor_it.get_line()

801 802
        cursor_chunk, _, _ = self.linediffer.locate_chunk(
            new_pane, cursor_line)
803 804 805 806
        if cursor_chunk is not None:
            already_in_chunk = cursor_chunk == chunk
        else:
            cursor_chunk = self._synth_chunk(pane, new_pane, cursor_line)
807 808
            already_in_chunk = (
                cursor_chunk[3] == new_start and cursor_chunk[4] == new_end)
809 810 811 812 813 814 815 816 817 818 819 820 821 822 823

        if already_in_chunk:
            new_line = cursor_line
        else:
            # Guess where to put the cursor: in the same chunk, at about the
            # same place within the chunk, calculated proportionally by line.
            # Insert chunks and one-line chunks are placed at the top.
            if cur_end == cur_start:
                chunk_offset = 0.0
            else:
                chunk_offset = (line - cur_start) / float(cur_end - cur_start)
            new_line = new_start + int(chunk_offset * (new_end - new_start))

        return new_line

824
    def move_cursor(self, pane, line, focus=True):
825
        buf, view = self.textbuffer[pane], self.textview[pane]
826 827
        if focus:
            view.grab_focus()
828 829 830
        buf.place_cursor(buf.get_iter_at_line(line))
        view.scroll_to_mark(buf.get_insert(), 0.1, True, 0.5, 0.5)

831
    def move_cursor_pane(self, pane, new_pane):
832 833
        chunk, line = self.cursor.chunk, self.cursor.line
        new_line = self._corresponding_chunk_line(chunk, line, pane, new_pane)
834
        self.move_cursor(new_pane, new_line)
835

836
    @Template.Callback()
837 838 839 840 841
    def action_prev_pane(self, *args):
        pane = self._get_focused_pane()
        new_pane = (pane - 1) % self.num_panes
        self.move_cursor_pane(pane, new_pane)

842
    @Template.Callback()
843 844 845 846 847
    def action_next_pane(self, *args):
        pane = self._get_focused_pane()
        new_pane = (pane + 1) % self.num_panes
        self.move_cursor_pane(pane, new_pane)

848 849 850 851 852 853 854 855
    def _set_external_action_sensitivity(self):
        have_file = self.focus_pane is not None
        try:
            self.main_actiongroup.get_action("OpenExternal").set_sensitive(
                have_file)
        except AttributeError:
            pass

856 857 858 859 860 861 862
    def on_textview_drag_data_received(
            self, widget, context, x, y, selection_data, info, time):
        uris = selection_data.get_uris()
        if uris:
            gfiles = [Gio.File.new_for_uri(uri) for uri in uris]

            if len(gfiles) == self.num_panes:
863
                if self.check_unsaved_changes():
864 865 866 867
                    self.set_files(gfiles)
            elif len(gfiles) == 1:
                pane = self.textview.index(widget)
                buffer = self.textbuffer[pane]
868
                if self.check_unsaved_changes([buffer]):
869 870 871
                    self.set_file(pane, gfiles[0])
            return True

872
    @Template.Callback()
873
    def on_textview_focus_in_event(self, view, event):
874
        self.focus_pane = view
875
        self.findbar.textview = view
876
        self.on_cursor_position_changed(view.get_buffer(), None, True)
877
        self._set_save_action_sensitivity()
878
        self._set_merge_action_sensitivity()
879
        self._set_external_action_sensitivity()
880
        self.update_text_actions_sensitivity()
881

882
    @Template.Callback()
883
    def on_textview_focus_out_event(self, view, event):
884
        self.keymask = 0
885
        self._set_merge_action_sensitivity()
886
        self._set_external_action_sensitivity()
887

888
    def _after_text_modified(self, buf, startline, sizechange):
889
        if self.num_panes > 1:
890
            pane = self.textbuffer.index(buf)
891
            if not self.linediffer.syncpoints:
892 893
                self.linediffer.change_sequence(pane, startline, sizechange,
                                                self.buffer_filtered)
894 895 896
            # TODO: We should have a diff-changed signal for the
            # current buffer instead of passing everything through
            # cursor change logic.
897 898 899 900
            focused_pane = self._get_focused_pane()
            if focused_pane != -1:
                self.on_cursor_position_changed(self.textbuffer[focused_pane],
                                                None, True)
Stephen Kennedy's avatar
Stephen Kennedy committed
901
            self.queue_draw()
steve9000's avatar
steve9000 committed
902

903
    def _filter_text(self, txt, buf, txt_start_iter, txt_end_iter):
904
        dimmed_tag = buf.get_tag_table().lookup("dimmed")
905
        buf.remove_tag(dimmed_tag, txt_start_iter, txt_end_iter)
906

907
        def highlighter(start, end):
908 909 910 911 912
            start_iter = txt_start_iter.copy()
            start_iter.forward_chars(start)
            end_iter = txt_start_iter.copy()
            end_iter.forward_chars(end)
            buf.apply_tag(dimmed_tag, start_iter, end_iter)
913

914 915
        try:
            regexes = [f.filter for f in self.text_filters if f.active]
916
            txt = misc.apply_text_filters(txt, regexes, apply_fn=highlighter)
917
        except AssertionError:
918
            if not self.warned_bad_comparison:
919
                misc.error_dialog(
920
                    primary=_("Comparison results will be inaccurate"),
921
                    secondary=_(
922 923 924
                        "A filter changed the number of lines in the "
                        "file, which is unsupported. The comparison will "
                        "not be accurate."),
925
                )
926
                self.warned_bad_comparison = True
927

928 929
        return txt

930 931 932 933 934 935
    def after_text_insert_text(self, buf, it, newtext, textlen):
        start_mark = buf.get_mark("insertion-start")
        starting_at = buf.get_iter_at_mark(start_mark).get_line()
        buf.delete_mark(start_mark)
        lines_added = it.get_line() - starting_at
        self._after_text_modified(buf, starting_at, lines_added)
936

937
    def after_text_delete_range(self, buf, it0, it1):
938
        starting_at = it0.get_line()
939 940
        self._after_text_modified(buf, starting_at, -self.lines_removed)
        self.lines_removed = 0
941

942
    def check_save_modified(self, buffers=None):
943
        response = Gtk.ResponseType.OK
944
        buffers = buffers or self.textbuffer[:self.num_panes]
945
        if any(b.get_modified() for b in buffers):
946 947 948
            builder = Gtk.Builder.new_from_resource(
                '/org/gnome/meld/ui/save-confirm-dialog.ui')
            dialog = builder.get_object('save-confirm-dialog')
949
            dialog.set_transient_for(self.get_toplevel())
950 951
            message_area = dialog.get_message_area()

952
            buttons = []
953 954 955 956 957
            for buf in buffers:
                button = Gtk.CheckButton.new_with_label(buf.data.label)
                needs_save = buf.get_modified()
                button.set_sensitive(needs_save)
                button.set_active(needs_save)
958 959
                message_area.pack_start(
                    button, expand=False, fill=True, padding=0)
960
                buttons.append(button)
961
            message_area.show_all()
962

963
            response = dialog.run()
964
            try_save = [b.get_active() for b in buttons]
965
            dialog.destroy()
966

967 968
            if response == Gtk.ResponseType.OK:
                for i, buf in enumerate(buffers):
969
                    if try_save[i]:
970
                        self.save_file(self.textbuffer.index(buf))
971

972 973 974 975 976 977 978 979 980
                # Regardless of whether these saves are successful or not,
                # we return a cancel here, so that other closing logic
                # doesn't run. Instead, the file-saved callback from
                # save_file() handles closing files and setting state.
                return Gtk.ResponseType.CANCEL
            elif response == Gtk.ResponseType.DELETE_EVENT:
                response = Gtk.ResponseType.CANCEL
            elif response == Gtk.ResponseType.CLOSE:
                response = Gtk.ResponseType.OK
981

982
        if response == Gtk.ResponseType.OK and self.meta:
983
            self.prompt_resolve_conflict()
984
        elif response == Gtk.ResponseType.CANCEL:
985
            self.state = ComparisonState.Normal
986

987 988
        return response

989 990 991 992 993 994 995 996 997 998 999 1000
    def prompt_resolve_conflict(self):
        parent = self.meta.get('parent', None)
        saved = self.meta.get('middle_saved', False)
        prompt_resolve = self.meta.get('prompt_resolve', False)
        if prompt_resolve and saved and parent.has_command('resolve'):
            primary = _("Mark conflict as resolved?")
            secondary = _(
                "If the conflict was resolved successfully, you may mark "
                "it as resolved now.")
            buttons = ((_("Cancel"), Gtk.ResponseType.CANCEL),
                       (_("Mark _Resolved"), Gtk.ResponseType.OK))
            resolve_response = misc.modal_dialog(
1001
                primary, secondary, buttons, parent=self,
1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012
                messagetype=Gtk.MessageType.QUESTION)

            if resolve_response == Gtk.ResponseType.OK:
                bufdata = self.textbuffer[1].data
                conflict_gfile = bufdata.savefile or bufdata.gfile
                # It's possible that here we're in a quit callback,
                # so we can't schedule the resolve action to an
                # idle loop; it might never happen.
                parent.command(
                    'resolve', [conflict_gfile.get_path()], sync=True)

1013
    def on_delete_event(self):
1014
        self.state = ComparisonState.Closing
1015
        response = self.check_save_modified()
1016
        if response == Gtk.ResponseType.OK:
1017 1018
            for h in self.settings_handlers:
                meldsettings.disconnect(h)
1019 1020 1021
            # TODO: This should not be necessary; remove if and when we
            # figure out what's keeping MeldDocs alive for too long.
            del self._cached_match
1022
            # TODO: Base the return code on something meaningful for VC tools
1023
            self.close_signal.emit(0)
1024
        return response
steve9000's avatar
steve9000 committed
1025

1026 1027 1028 1029 1030 1031 1032 1033 1034
    def _scroll_to_actions(self, actions):
        """Scroll all views affected by *actions* to the current cursor"""

        affected_buffers = set(a.buffer for a in actions)
        for buf in affected_buffers:
            buf_index = self.textbuffer.index(buf)
            view = self.textview[buf_index]
            view.scroll_mark_onscreen(buf.get_insert())

1035 1036
    def on_undo_activate(self):
        if self.undosequence.can_undo():
1037 1038
            actions = self.undosequence.undo()
        self._scroll_to_actions(actions)
1039 1040 1041

    def on_redo_activate(self):
        if self.undosequence.can_redo():
1042 1043
            actions = self.undosequence.redo()
        self._scroll_to_actions(actions)
1044

1045
    def on_text_insert_text(self, buf, it, text, textlen):
1046
        self.undosequence.add_action(
1047
            BufferInsertionAction(buf, it.get_offset(), text))
1048
        buf.create_mark("insertion-start", it, True)
steve9000's avatar
steve9000 committed
1049

1050
    def on_text_delete_range(self, buf, it0, it1):
1051
        text = buf.get_text(it0, it1, False)
1052
        self.lines_removed = it1.get_line() - it0.get_line()
1053
        self.undosequence.add_action(
1054
            BufferDeletionAction(buf, it0.get_offset(), text))
1055 1056

    def on_undo_checkpointed(self, undosequence, buf, checkpointed):
1057
        buf.set_modified(not checkpointed)
1058
        self.recompute_label()
1059

1060 1061
    @with_focused_pane
    def open_external(self, pane):
1062
        if not self.textbuffer[pane].data.gfile:
1063 1064 1065 1066
            return
        pos = self.textbuffer[pane].props.cursor_position
        cursor_it = self.textbuffer[pane].get_iter_at_offset(pos)
        line = cursor_it.get_line() + 1
1067 1068 1069
        # TODO: Support URI-based opens
        path = self.textbuffer[pane].data.gfile.get_path()
        self._open_files([path], line)
1070

1071 1072 1073 1074 1075 1076 1077 1078 1079
    def update_text_actions_sensitivity(self, *args):
        widget = self.focus_pane
        if not widget:
            cut, copy, paste = False, False, False
        else:
            cut = copy = widget.get_buffer().get_has_selection()
            # Ideally, this would check whether the clipboard included
            # something pasteable. However, there is no changed signal.
            # widget.get_clipboard(
1080
            #    Gdk.SELECTION_CLIPBOARD).wait_is_text_available()
1081
            paste = widget.get_editable()
1082 1083 1084 1085
        if self.main_actiongroup:
            for action, sens in zip(
                    ("Cut", "Copy", "Paste"), (cut, copy, paste)):
                self.main_actiongroup.get_action(action).set_sensitive(sens)
1086

1087 1088
    @with_focused_pane
    def get_selected_text(self, pane):
1089
        """Returns selected text of active pane"""
1090 1091 1092
        buf = self.textbuffer[pane]
        sel = buf.get_selection_bounds()
        if sel:
1093
            return buf.get_text(sel[0], sel[1], False)
1094

1095
    def on_find_activate(self, *args):
1096
        selected_text = self.get_selected_text()
1097
        self.findbar.start_find(self.focus_pane, selected_text)
1098 1099 1100
        self.keymask = 0

    def on_replace_activate(self, *args):
1101
        selected_text = self.get_selected_text()
1102
        self.findbar.start_replace(self.focus_pane, selected_text)
1103 1104 1105
        self.keymask = 0

    def on_find_next_activate(self, *args):
1106
        self.findbar.start_find_next(self.focus_pane)
1107

1108
    def on_find_previous_activate(self, *args):
1109
        self.findbar.start_find_previous(self.focus_pane)
1110

1111 1112 1113 1114
    @with_focused_pane
    def on_go_to_line_activate(self, pane, *args):
        self.statusbar[pane].emit('start-go-to-line')

1115
    @Template.Callback()
1116
    def on_scrolledwindow_size_allocate(self, scrolledwindow, allocation):
1117 1118 1119 1120 1121 1122
        index = self.scrolledwindow.index(scrolledwindow)
        if index == 0 or index == 1:
            self.linkmap[0].queue_draw()
        if index == 1 or index == 2:
            self.linkmap[1].queue_draw()

1123
    @Template.Callback()
1124
    def on_textview_popup_menu(self, textview):
1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137
        buffer = textview.get_buffer()
        cursor_it = buffer.get_iter_at_mark(buffer.get_insert())
        location = textview.get_iter_location(cursor_it)

        rect = Gdk.Rectangle()
        rect.x, rect.y = textview.buffer_to_window_coords(
            Gtk.TextWindowType.WIDGET, location.x, location.y)

        self.popup_menu.popup_at_rect(
            Gtk.Widget.get_window(textview),
            rect,
            Gdk.Gravity.SOUTH_EAST,
            Gdk.Gravity.NORTH_WEST,
Kai Willadsen's avatar