misc.py 15.6 KB
Newer Older
1 2 3 4
# Copyright (C) 2002-2006 Stephen Kennedy <stevek@gnome.org>
# Copyright (C) 2009 Vincent Legoll <vincent.legoll@gmail.com>
# Copyright (C) 2012-2013 Kai Willadsen <kai.willadsen@gmail.com>
#
5 6 7 8 9 10 11 12 13 14 15 16
# 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
17 18 19

"""Module of commonly used helper classes and functions
"""
20 21

import os
steve9000's avatar
steve9000 committed
22 23
import errno
import shutil
steve9000's avatar
steve9000 committed
24
import re
25
import subprocess
26

27 28
from gi.repository import Gdk
from gi.repository import GObject
29
from gi.repository import Gtk
30
from gi.repository import GtkSource
31

32 33
from meld.conf import _

34

35
if os.name != "nt":
36 37 38 39 40 41 42 43
    from select import select
else:
    import time

    def select(rlist, wlist, xlist, timeout):
        time.sleep(timeout)
        return rlist, wlist, xlist

steve9000's avatar
steve9000 committed
44

45
def error_dialog(primary, secondary):
46 47 48 49 50 51 52 53
    """A common error dialog handler for Meld

    This should only ever be used as a last resort, and for errors that
    a user is unlikely to encounter. If you're tempted to use this,
    think twice.

    Primary must be plain text. Secondary must be valid markup.
    """
54 55 56
    return modal_dialog(
        primary, secondary, Gtk.ButtonsType.CLOSE, parent=None,
        messagetype=Gtk.MessageType.ERROR)
57 58


59 60 61 62 63 64 65 66 67 68 69 70 71
def modal_dialog(
        primary, secondary, buttons, parent=None,
        messagetype=Gtk.MessageType.WARNING):
    """A common message dialog handler for Meld

    This should only ever be used for interactions that must be resolved
    before the application flow can continue.

    Primary must be plain text. Secondary must be valid markup.
    """

    if not parent:
        from meld.meldapp import app
72
        parent = app.get_active_window()
73 74 75
    elif not isinstance(parent, Gtk.Window):
        parent = parent.get_toplevel()

76 77 78 79 80
    if isinstance(buttons, Gtk.ButtonsType):
        custom_buttons = []
    else:
        custom_buttons, buttons = buttons, Gtk.ButtonsType.NONE

81
    dialog = Gtk.MessageDialog(
82 83 84 85
        transient_for=parent,
        modal=True,
        destroy_with_parent=True,
        message_type=messagetype,
86
        buttons=buttons,
87
        text=primary)
88 89
    dialog.format_secondary_markup(secondary)

90
    for label, response_id in custom_buttons:
91 92 93 94 95 96 97
        dialog.add_button(label, response_id)

    response = dialog.run()
    dialog.destroy()
    return response


98
# Taken from epiphany
99
def position_menu_under_widget(menu, x, y, widget):
100
    container = widget.get_ancestor(Gtk.Container)
101

102 103 104
    widget_width = widget.get_allocation().width
    menu_width = menu.get_allocation().width
    menu_height = menu.get_allocation().height
105 106

    screen = menu.get_screen()
107
    monitor_num = screen.get_monitor_at_window(widget.get_window())
108 109 110 111
    if monitor_num < 0:
        monitor_num = 0
    monitor = screen.get_monitor_geometry(monitor_num)

112 113 114 115 116
    unused, x, y = widget.get_window().get_origin()
    allocation = widget.get_allocation()
    if not widget.get_has_window():
        x += allocation.x
        y += allocation.y
117

118 119
    if container.get_direction() == Gtk.TextDirection.LTR:
        x += allocation.width - widget_width
120 121 122
    else:
        x += widget_width - menu_width

123 124
    if (y + allocation.height + menu_height) <= monitor.y + monitor.height:
        y += allocation.height
125 126
    elif (y - menu_height) >= monitor.y:
        y -= menu_height
127 128
    elif monitor.y + monitor.height - (y + allocation.height) > y:
        y += allocation.height
129 130 131 132 133
    else:
        y -= menu_height

    return (x, y, False)

134

135 136
def make_tool_button_widget(label):
    """Make a GtkToolButton label-widget suggestive of a menu dropdown"""
137 138
    arrow = Gtk.Arrow(
        arrow_type=Gtk.ArrowType.DOWN, shadow_type=Gtk.ShadowType.NONE)
139 140 141 142
    label = Gtk.Label(label=label)
    hbox = Gtk.HBox(spacing=3)
    hbox.pack_end(arrow, True, True, 0)
    hbox.pack_end(label, True, True, 0)
143 144 145
    hbox.show_all()
    return hbox

146

147 148 149
MELD_STYLE_SCHEME = "meld-base"


150 151 152 153 154 155 156 157 158 159 160
def parse_rgba(string):
    """Parse a string to a Gdk.RGBA across different GTK+ APIs

    Introspection changes broke this API in GTK+ 3.20; this function
    is just a backwards-compatiblity workaround.
    """
    colour = Gdk.RGBA()
    result = colour.parse(string)
    return result[1] if isinstance(result, tuple) else colour


161 162 163 164
def colour_lookup_with_fallback(name, attribute):
    from meld.settings import meldsettings
    source_style = meldsettings.style_scheme

165
    style = source_style.get_style(name)
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182
    style_attr = getattr(style.props, attribute) if style else None
    if not style or not style_attr:
        manager = GtkSource.StyleSchemeManager.get_default()
        source_style = manager.get_scheme(MELD_STYLE_SCHEME)
        try:
            style = source_style.get_style(name)
            style_attr = getattr(style.props, attribute)
        except AttributeError:
            pass

    if not style_attr:
        import sys
        print >> sys.stderr, _(
            "Couldn't find colour scheme details for %s-%s; "
            "this is a bad install") % (name, attribute)
        sys.exit(1)

183
    return parse_rgba(style_attr)
184 185


186
def get_common_theme():
187
    lookup = colour_lookup_with_fallback
188
    fill_colours = {
189 190 191 192
        "insert": lookup("meld:insert", "background"),
        "delete": lookup("meld:insert", "background"),
        "conflict": lookup("meld:conflict", "background"),
        "replace": lookup("meld:replace", "background"),
193
        "current-chunk-highlight": lookup(
194
            "meld:current-chunk-highlight", "background")
195 196
    }
    line_colours = {
197 198 199 200
        "insert": lookup("meld:insert", "line-background"),
        "delete": lookup("meld:insert", "line-background"),
        "conflict": lookup("meld:conflict", "line-background"),
        "replace": lookup("meld:replace", "line-background"),
201 202 203 204
    }
    return fill_colours, line_colours


205 206 207
def gdk_to_cairo_color(color):
    return (color.red / 65535., color.green / 65535., color.blue / 65535.)

208

209 210 211 212 213 214 215 216 217
def fallback_decode(bytes, encodings, lossy=False):
    """Try and decode bytes according to multiple encodings

    Generally, this should be used for best-effort decoding, when the
    desired behaviour is "probably this, or UTF-8".

    If lossy is True, then decode errors will be replaced. This may be
    reasonable when the string is for display only.
    """
218 219 220
    if isinstance(bytes, unicode):
        return bytes

221 222 223 224 225 226 227 228 229 230 231 232 233
    for encoding in encodings:
        try:
            return bytes.decode(encoding)
        except UnicodeDecodeError:
            pass

    if lossy:
        return bytes.decode(encoding, errors='replace')

    raise ValueError(
        "Couldn't decode %r as one of %r" % (bytes, encodings))


234 235 236
def all_same(lst):
    """Return True if all elements of the list are equal"""
    return not lst or lst.count(lst[0]) == len(lst)
237 238


239
def shorten_names(*names):
steve9000's avatar
steve9000 committed
240 241
    """Remove redunant parts of a list of names (e.g. /tmp/foo{1,2} -> foo{1,2}
    """
242 243
    # TODO: Update for different path separators
    prefix = os.path.commonprefix(names)
244
    prefixslash = prefix.rfind("/") + 1
245 246 247

    names = [n[prefixslash:] for n in names]
    paths = [n.split("/") for n in names]
steve9000's avatar
steve9000 committed
248 249

    try:
250
        basenames = [p[-1] for p in paths]
steve9000's avatar
steve9000 committed
251 252
    except IndexError:
        pass
253
    else:
254
        if all_same(basenames):
255 256 257
            def firstpart(alist):
                if len(alist) > 1:
                    return "[%s] " % alist[0]
258 259
                else:
                    return ""
260
            roots = [firstpart(p) for p in paths]
steve9000's avatar
steve9000 committed
261
            base = basenames[0].strip()
262
            return [r + base for r in roots]
steve9000's avatar
steve9000 committed
263
    # no common path. empty names get changed to "[None]"
264
    return [name or _("[None]") for name in basenames]
265

266

267
def read_pipe_iter(command, workdir, errorstream, yield_interval=0.1):
steve9000's avatar
steve9000 committed
268 269 270
    """Read the output of a shell command iteratively.

    Each time 'callback_interval' seconds pass without reading any data,
271 272
    this function yields None.
    When all the data is read, the entire string is yielded.
steve9000's avatar
steve9000 committed
273
    """
274
    class sentinel(object):
275
        def __init__(self):
276
            self.proc = None
277

steve9000's avatar
steve9000 committed
278
        def __del__(self):
279
            if self.proc:
280
                errorstream.error("killing '%s'\n" % command[0])
281
                self.proc.terminate()
282 283 284
                errorstream.error("killed (status was '%i')\n" %
                                  self.proc.wait())

steve9000's avatar
steve9000 committed
285
        def __call__(self):
286 287 288 289
            self.proc = subprocess.Popen(command, cwd=workdir,
                                         stdin=subprocess.PIPE,
                                         stdout=subprocess.PIPE,
                                         stderr=subprocess.PIPE)
290 291
            self.proc.stdin.close()
            childout, childerr = self.proc.stdout, self.proc.stderr
steve9000's avatar
steve9000 committed
292 293
            bits = []
            while len(bits) == 0 or bits[-1] != "":
294 295
                state = select([childout, childerr], [], [childout, childerr],
                               yield_interval)
steve9000's avatar
steve9000 committed
296 297 298 299
                if len(state[0]) == 0:
                    if len(state[2]) == 0:
                        yield None
                    else:
300
                        raise Exception("Error reading pipe")
steve9000's avatar
steve9000 committed
301 302
                if childout in state[0]:
                    try:
303 304
                        # get buffer size
                        bits.append(childout.read(4096))
steve9000's avatar
steve9000 committed
305
                    except IOError:
306 307
                        # FIXME: ick need to fix
                        break
steve9000's avatar
steve9000 committed
308 309
                if childerr in state[0]:
                    try:
310 311
                        # how many chars?
                        errorstream.error(childerr.read(1))
steve9000's avatar
steve9000 committed
312
                    except IOError:
313 314
                        # FIXME: ick need to fix
                        break
315
            status = self.proc.wait()
316
            errorstream.error(childerr.read())
317
            self.proc = None
steve9000's avatar
steve9000 committed
318
            if status:
319
                errorstream.error("Exit code: %i\n" % status)
320
            yield status, "".join(bits)
steve9000's avatar
steve9000 committed
321
    return sentinel()()
steve9000's avatar
steve9000 committed
322

323

324
def write_pipe(command, text, error=None):
325
    """Write 'text' into a shell command and discard its stdout output.
steve9000's avatar
steve9000 committed
326
    """
327 328
    proc = subprocess.Popen(command, stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE, stderr=error)
329 330
    proc.communicate(text)
    return proc.wait()
331

332

333
def copy2(src, dst):
334
    """Like shutil.copy2 but ignores chmod errors, and copies symlinks as links
335 336 337 338
    See [Bug 568000] Copying to NTFS fails
    """
    if os.path.isdir(dst):
        dst = os.path.join(dst, os.path.basename(src))
339 340

    if os.path.islink(src) and os.path.isfile(src):
341 342
        if os.path.lexists(dst):
            os.unlink(dst)
343 344 345 346 347 348
        os.symlink(os.readlink(src), dst)
    elif os.path.isfile(src):
        shutil.copyfile(src, dst)
    else:
        raise OSError("Not a file")

349 350
    try:
        shutil.copystat(src, dst)
351
    except OSError as e:
352
        if e.errno not in (errno.EPERM, errno.ENOTSUP):
353 354
            raise

355

356 357 358 359 360 361 362 363 364
def copytree(src, dst):
    """Similar to shutil.copytree, but always copies symlinks and doesn't
    error out if the destination path already exists.
    """
    # If the source tree is a symlink, duplicate the link and we're done.
    if os.path.islink(src):
        os.symlink(os.readlink(src), dst)
        return

steve9000's avatar
steve9000 committed
365 366
    try:
        os.mkdir(dst)
367
    except OSError as e:
steve9000's avatar
steve9000 committed
368 369 370 371 372 373
        if e.errno != errno.EEXIST:
            raise
    names = os.listdir(src)
    for name in names:
        srcname = os.path.join(src, name)
        dstname = os.path.join(dst, name)
374
        if os.path.islink(srcname):
375
            os.symlink(os.readlink(srcname), dstname)
steve9000's avatar
steve9000 committed
376
        elif os.path.isdir(srcname):
377
            copytree(srcname, dstname)
steve9000's avatar
steve9000 committed
378
        else:
379
            copy2(srcname, dstname)
steve9000's avatar
steve9000 committed
380

381 382
    try:
        shutil.copystat(src, dst)
383
    except OSError as e:
384 385 386
        if e.errno != errno.EPERM:
            raise

387

388 389 390 391 392
def shell_escape(glob_pat):
    # TODO: handle all cases
    assert not re.compile(r"[][*?]").findall(glob_pat)
    return glob_pat.replace('{', '[{]').replace('}', '[}]')

393

steve9000's avatar
steve9000 committed
394 395 396
def shell_to_regex(pat):
    """Translate a shell PATTERN to a regular expression.

397 398
    Based on fnmatch.translate().
    We also handle {a,b,c} where fnmatch does not.
steve9000's avatar
steve9000 committed
399 400 401 402 403 404
    """

    i, n = 0, len(pat)
    res = ''
    while i < n:
        c = pat[i]
405 406 407 408 409 410 411 412 413 414
        i += 1
        if c == '\\':
            try:
                c = pat[i]
            except IndexError:
                pass
            else:
                i += 1
                res += re.escape(c)
        elif c == '*':
steve9000's avatar
steve9000 committed
415 416 417 418 419 420
            res += '.*'
        elif c == '?':
            res += '.'
        elif c == '[':
            try:
                j = pat.index(']', i)
421 422 423
            except ValueError:
                res += r'\['
            else:
steve9000's avatar
steve9000 committed
424
                stuff = pat[i:j]
425
                i = j + 1
steve9000's avatar
steve9000 committed
426 427 428 429 430 431 432 433
                if stuff[0] == '!':
                    stuff = '^%s' % stuff[1:]
                elif stuff[0] == '^':
                    stuff = r'\^%s' % stuff[1:]
                res += '[%s]' % stuff
        elif c == '{':
            try:
                j = pat.index('}', i)
434 435 436
            except ValueError:
                res += '\\{'
            else:
steve9000's avatar
steve9000 committed
437
                stuff = pat[i:j]
438
                i = j + 1
439 440 441
                res += '(%s)' % "|".join(
                    [shell_to_regex(p)[:-1] for p in stuff.split(",")]
                )
steve9000's avatar
steve9000 committed
442 443 444
        else:
            res += re.escape(c)
    return res + "$"
445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479


def merge_intervals(interval_list):
    """Merge a list of intervals

    Returns a list of itervals as 2-tuples with all overlapping
    intervals merged.

    interval_list must be a list of 2-tuples of integers representing
    the start and end of an interval.
    """

    if len(interval_list) < 2:
        return interval_list

    interval_list.sort()
    merged_intervals = [interval_list.pop(0)]
    current_start, current_end = merged_intervals[-1]

    while interval_list:
        new_start, new_end = interval_list.pop(0)

        if current_end >= new_end:
            continue

        if current_end < new_start:
            # Intervals do not overlap; create a new one
            merged_intervals.append((new_start, new_end))
        elif current_end < new_end:
            # Intervals overlap; extend the current one
            merged_intervals[-1] = (current_start, new_end)

        current_start, current_end = merged_intervals[-1]

    return merged_intervals
480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515


def apply_text_filters(txt, regexes, cutter=lambda txt, start, end:
                       txt[:start] + txt[end:]):
    """Apply text filters

    Text filters "regexes", resolved as regular expressions are applied
    to "txt".

    "cutter" defines the way how to apply them. Default is to just cut
    out the matches.
    """
    filter_ranges = []
    for r in regexes:
        for match in r.finditer(txt):

            # If there are no groups in the match, use the whole match
            if not r.groups:
                span = match.span()
                if span[0] != span[1]:
                    filter_ranges.append(span)
                continue

            # If there are groups in the regex, include all groups that
            # participated in the match
            for i in range(r.groups):
                span = match.span(i + 1)
                if span != (-1, -1) and span[0] != span[1]:
                    filter_ranges.append(span)
                    
    filter_ranges = merge_intervals(filter_ranges)

    for (start, end) in reversed(filter_ranges):
        txt = cutter(txt, start, end)

    return txt