1# Copyright (C) 2013-2014 Kai Willadsen <kai.willadsen@gmail.com>
2#
3# This program is free software: you can redistribute it and/or modify
4# it under the terms of the GNU General Public License as published by
5# the Free Software Foundation, either version 2 of the License, or (at
6# your option) any later version.
7#
8# This program is distributed in the hope that it will be useful, but
9# WITHOUT ANY WARRANTY; without even the implied warranty of
10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
11# General Public License for more details.
12#
13# You should have received a copy of the GNU General Public License
14# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15
16import math
17
18from gi.repository import Gdk
19from gi.repository import Gtk
20from gi.repository import GtkSource
21from gi.repository import Pango
22
23from meld.conf import _
24from meld.const import MODE_DELETE, MODE_INSERT, MODE_REPLACE
25from meld.misc import get_common_theme
26from meld.settings import meldsettings
27from meld.ui.gtkcompat import get_style
28
29# Fixed size of the renderer. Ideally this would be font-dependent and
30# would adjust to other textview attributes, but that's both quite difficult
31# and not necessarily desirable.
32LINE_HEIGHT = 16
33
34GTK_RENDERER_STATE_MAPPING = {
35    GtkSource.GutterRendererState.NORMAL: Gtk.StateFlags.NORMAL,
36    GtkSource.GutterRendererState.CURSOR: Gtk.StateFlags.FOCUSED,
37    GtkSource.GutterRendererState.PRELIT: Gtk.StateFlags.PRELIGHT,
38    GtkSource.GutterRendererState.SELECTED: Gtk.StateFlags.SELECTED,
39}
40
41ALIGN_MODE_FIRST = GtkSource.GutterRendererAlignmentMode.FIRST
42
43
44def load(icon_name):
45    icon_theme = Gtk.IconTheme.get_default()
46    return icon_theme.load_icon(icon_name, LINE_HEIGHT, 0)
47
48
49def get_background_rgba(renderer):
50    '''Get and cache the expected background for the renderer widget
51
52    Current versions of GTK+ don't paint the background of text view
53    gutters with the actual expected widget background, which causes
54    them to look wrong when put next to any other widgets. This hack
55    just gets the background from the renderer's view, and then caches
56    it for performance, and on the basis that all renderers will be
57    assigned to similarly-styled views. This is fragile, but the
58    alternative is really significantly slower.
59    '''
60    global _background_rgba
61    if _background_rgba is None:
62        if renderer.props.view:
63            stylecontext = renderer.props.view.get_style_context()
64            background_set, _background_rgba = (
65                stylecontext.lookup_color('theme_bg_color'))
66    return _background_rgba
67
68
69_background_rgba = None
70
71
72def renderer_to_gtk_state(state):
73    gtk_state = Gtk.StateFlags(0)
74    for renderer_flag, gtk_flag in GTK_RENDERER_STATE_MAPPING.items():
75        if renderer_flag & state:
76            gtk_state |= gtk_flag
77    return gtk_state
78
79
80class MeldGutterRenderer:
81
82    def set_renderer_defaults(self):
83        self.set_alignment_mode(GtkSource.GutterRendererAlignmentMode.FIRST)
84        self.set_padding(3, 0)
85        self.set_alignment(0.5, 0.5)
86
87    def on_setting_changed(self, meldsettings, key):
88        if key == 'style-scheme':
89            self.fill_colors, self.line_colors = get_common_theme()
90            alpha = self.fill_colors['current-chunk-highlight'].alpha
91            self.chunk_highlights = {
92                state: Gdk.RGBA(*[alpha + c * (1.0 - alpha) for c in colour])
93                for state, colour in self.fill_colors.items()
94            }
95
96    def draw_chunks(
97            self, context, background_area, cell_area, start, end, state):
98
99        chunk = self._chunk
100        if not chunk:
101            return
102
103        line = start.get_line()
104        is_first_line = line == chunk[1]
105        is_last_line = line == chunk[2] - 1
106        if not (is_first_line or is_last_line):
107            # Only paint for the first and last lines of a chunk
108            return
109
110        x = background_area.x - 1
111        y = background_area.y
112        width = background_area.width + 2
113        height = 1 if chunk[1] == chunk[2] else background_area.height
114
115        context.set_line_width(1.0)
116        Gdk.cairo_set_source_rgba(context, self.line_colors[chunk[0]])
117        if is_first_line:
118            context.move_to(x, y + 0.5)
119            context.rel_line_to(width, 0)
120        if is_last_line:
121            context.move_to(x, y - 0.5 + height)
122            context.rel_line_to(width, 0)
123        context.stroke()
124
125    def query_chunks(self, start, end, state):
126        line = start.get_line()
127        chunk_index = self.linediffer.locate_chunk(self.from_pane, line)[0]
128        in_chunk = chunk_index is not None
129
130        chunk = None
131        if in_chunk:
132            chunk = self.linediffer.get_chunk(
133                chunk_index, self.from_pane, self.to_pane)
134
135        if chunk is not None:
136            if chunk[1] == chunk[2]:
137                background_rgba = get_background_rgba(self)
138            elif self.props.view.current_chunk_check(chunk):
139                background_rgba = self.chunk_highlights[chunk[0]]
140            else:
141                background_rgba = self.fill_colors[chunk[0]]
142        else:
143            # TODO: Remove when fixed in upstream GTK+
144            background_rgba = get_background_rgba(self)
145        self._chunk = chunk
146        self.set_background(background_rgba)
147        return in_chunk
148
149
150class GutterRendererChunkAction(
151        GtkSource.GutterRendererPixbuf, MeldGutterRenderer):
152    __gtype_name__ = "GutterRendererChunkAction"
153
154    ACTION_MAP = {
155        'LTR': {
156            MODE_REPLACE: load("meld-change-apply-right"),
157            MODE_DELETE: load("meld-change-delete"),
158            MODE_INSERT: load("meld-change-copy"),
159        },
160        'RTL': {
161            MODE_REPLACE: load("meld-change-apply-left"),
162            MODE_DELETE: load("meld-change-delete"),
163            MODE_INSERT: load("meld-change-copy"),
164        }
165    }
166
167    def __init__(self, from_pane, to_pane, views, filediff, linediffer):
168        super().__init__()
169        self.set_renderer_defaults()
170        self.from_pane = from_pane
171        self.to_pane = to_pane
172        # FIXME: Views are needed only for editable checking; connect to this
173        # in Filediff instead?
174        self.views = views
175        # FIXME: Don't pass in the linediffer; pass a generator like elsewhere
176        self.linediffer = linediffer
177        self.mode = MODE_REPLACE
178        self.set_size(LINE_HEIGHT)
179        direction = 'LTR' if from_pane < to_pane else 'RTL'
180        if self.views[0].get_direction() == Gtk.TextDirection.RTL:
181            direction = 'LTR' if direction == 'RTL' else 'RTL'
182
183        self.is_action = False
184        self.action_map = self.ACTION_MAP[direction]
185        self.filediff = filediff
186        self.filediff.connect("action-mode-changed",
187                              self.on_container_mode_changed)
188
189        meldsettings.connect('changed', self.on_setting_changed)
190        self.on_setting_changed(meldsettings, 'style-scheme')
191
192    def do_activate(self, start, area, event):
193        line = start.get_line()
194        chunk_index = self.linediffer.locate_chunk(self.from_pane, line)[0]
195        if chunk_index is None:
196            return
197
198        chunk = self.linediffer.get_chunk(
199            chunk_index, self.from_pane, self.to_pane)
200        if chunk[1] != line:
201            return
202
203        action = self._classify_change_actions(chunk)
204        if action == MODE_DELETE:
205            self.filediff.delete_chunk(self.from_pane, chunk)
206        elif action == MODE_INSERT:
207            copy_menu = self._make_copy_menu(chunk)
208            # TODO: Need a custom GtkMenuPositionFunc to position this next to
209            # the clicked gutter, not where the cursor is
210            copy_menu.popup(None, None, None, None, 0, event.time)
211        elif action == MODE_REPLACE:
212            self.filediff.replace_chunk(self.from_pane, self.to_pane, chunk)
213
214    def _make_copy_menu(self, chunk):
215        copy_menu = Gtk.Menu()
216        copy_up = Gtk.MenuItem.new_with_mnemonic(_("Copy _up"))
217        copy_down = Gtk.MenuItem.new_with_mnemonic(_("Copy _down"))
218        copy_menu.append(copy_up)
219        copy_menu.append(copy_down)
220        copy_menu.show_all()
221
222        # FIXME: This is horrible
223        widget = self.filediff.widget
224        copy_menu.attach_to_widget(widget, None)
225
226        def copy_chunk(widget, chunk, copy_up):
227            self.filediff.copy_chunk(self.from_pane, self.to_pane, chunk,
228                                     copy_up)
229
230        copy_up.connect('activate', copy_chunk, chunk, True)
231        copy_down.connect('activate', copy_chunk, chunk, False)
232        return copy_menu
233
234    def do_begin(self, *args):
235        self.views_editable = [v.get_editable() for v in self.views]
236
237    def do_draw(self, context, background_area, cell_area, start, end, state):
238        GtkSource.GutterRendererPixbuf.do_draw(
239            self, context, background_area, cell_area, start, end, state)
240        if self.is_action:
241            # TODO: Fix padding and min-height in CSS and use
242            # draw_style_common
243            style_context = get_style(None, "button.flat.image-button")
244            style_context.set_state(renderer_to_gtk_state(state))
245
246            x = background_area.x + 1
247            y = background_area.y + 1
248            width = background_area.width - 2
249            height = background_area.height - 2
250
251            Gtk.render_background(style_context, context, x, y, width, height)
252            Gtk.render_frame(style_context, context, x, y, width, height)
253
254            pixbuf = self.props.pixbuf
255            pix_width, pix_height = pixbuf.props.width, pixbuf.props.height
256
257            xalign, yalign = self.get_alignment()
258            align_mode = self.get_alignment_mode()
259            if align_mode == GtkSource.GutterRendererAlignmentMode.CELL:
260                icon_x = x + (width - pix_width) // 2
261                icon_y = y + (height - pix_height) // 2
262            else:
263                line_iter = start if align_mode == ALIGN_MODE_FIRST else end
264                textview = self.get_view()
265                loc = textview.get_iter_location(line_iter)
266                line_x, line_y = textview.buffer_to_window_coords(
267                    self.get_window_type(), loc.x, loc.y)
268                icon_x = cell_area.x + (cell_area.width - pix_width) * xalign
269                icon_y = line_y + (loc.height - pix_height) * yalign
270
271            Gtk.render_icon(style_context, context, pixbuf, icon_x, icon_y)
272
273        self.draw_chunks(
274            context, background_area, cell_area, start, end, state)
275
276    def do_query_activatable(self, start, area, event):
277        line = start.get_line()
278        chunk_index = self.linediffer.locate_chunk(self.from_pane, line)[0]
279        if chunk_index is not None:
280            # FIXME: This is all chunks, not just those shared with to_pane
281            chunk = self.linediffer.get_chunk(chunk_index, self.from_pane)
282            if chunk[1] == line:
283                return True
284        return False
285
286    def do_query_data(self, start, end, state):
287        self.query_chunks(start, end, state)
288        line = start.get_line()
289
290        if self._chunk and self._chunk[1] == line:
291            action = self._classify_change_actions(self._chunk)
292            pixbuf = self.action_map.get(action)
293        else:
294            pixbuf = None
295        self.is_action = bool(pixbuf)
296        self.props.pixbuf = pixbuf
297
298    def on_container_mode_changed(self, container, mode):
299        self.mode = mode
300        self.queue_draw()
301
302    def _classify_change_actions(self, change):
303        """Classify possible actions for the given change
304
305        Returns the action that can be performed given the content and
306        context of the change.
307        """
308        editable, other_editable = self.views_editable
309
310        if not editable and not other_editable:
311            return None
312
313        # Reclassify conflict changes, since we treat them the same as a
314        # normal two-way change as far as actions are concerned
315        change_type = change[0]
316        if change_type == "conflict":
317            if change[1] == change[2]:
318                change_type = "insert"
319            elif change[3] == change[4]:
320                change_type = "delete"
321            else:
322                change_type = "replace"
323
324        if change_type == 'insert':
325            return None
326
327        action = self.mode
328        if action == MODE_DELETE and not editable:
329            action = None
330        elif action == MODE_INSERT and change_type == 'delete':
331            action = MODE_REPLACE
332        if not other_editable:
333            action = MODE_DELETE
334        return action
335
336
337# GutterRendererChunkLines is an adaptation of GtkSourceGutterRendererLines
338# Copyright (C) 2010 - Jesse van den Kieboom
339#
340# Python reimplementation is Copyright (C) 2015 Kai Willadsen
341
342
343class GutterRendererChunkLines(
344        GtkSource.GutterRendererText, MeldGutterRenderer):
345    __gtype_name__ = "GutterRendererChunkLines"
346
347    def __init__(self, from_pane, to_pane, linediffer):
348        super().__init__()
349        self.set_renderer_defaults()
350        self.from_pane = from_pane
351        self.to_pane = to_pane
352        # FIXME: Don't pass in the linediffer; pass a generator like elsewhere
353        self.linediffer = linediffer
354
355        self.num_line_digits = 0
356        self.changed_handler_id = None
357
358        meldsettings.connect('changed', self.on_setting_changed)
359        self.on_setting_changed(meldsettings, 'style-scheme')
360
361    def do_change_buffer(self, old_buffer):
362        if old_buffer:
363            old_buffer.disconnect(self.changed_handler_id)
364
365        view = self.get_view()
366        if view:
367            buf = view.get_buffer()
368            if buf:
369                self.changed_handler_id = buf.connect(
370                    "changed", self.recalculate_size)
371                self.recalculate_size(buf)
372
373    def _measure_markup(self, markup):
374        layout = self.get_view().create_pango_layout()
375        layout.set_markup(markup)
376        w, h = layout.get_size()
377        return w / Pango.SCALE, h / Pango.SCALE
378
379    def recalculate_size(self, buf):
380
381        # Always calculate display size for at least two-digit line counts
382        num_lines = max(buf.get_line_count(), 99)
383        num_digits = int(math.ceil(math.log(num_lines, 10)))
384
385        if num_digits == self.num_line_digits:
386            return
387
388        self.num_line_digits = num_digits
389        markup = "<b>%d</b>" % num_lines
390        width, height = self._measure_markup(markup)
391        self.set_size(width)
392
393    def do_draw(self, context, background_area, cell_area, start, end, state):
394        GtkSource.GutterRendererText.do_draw(
395            self, context, background_area, cell_area, start, end, state)
396        self.draw_chunks(
397            context, background_area, cell_area, start, end, state)
398
399    def do_query_data(self, start, end, state):
400        self.query_chunks(start, end, state)
401        line = start.get_line() + 1
402        current_line = state & GtkSource.GutterRendererState.CURSOR
403        markup = "<b>%d</b>" % line if current_line else str(line)
404        self.set_markup(markup, -1)
405