From 1451573bc815963628080a3bd4088be76391dcc3 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 26 Feb 2015 16:21:07 +0530 Subject: [PATCH] Wire up Open With --- src/calibre/gui2/book_details.py | 49 +++++++++- src/calibre/gui2/init.py | 20 ++++- src/calibre/gui2/open_with.py | 148 ++++++++++++++++++++++++++----- 3 files changed, 191 insertions(+), 26 deletions(-) diff --git a/src/calibre/gui2/book_details.py b/src/calibre/gui2/book_details.py index 76da38b7ee..e5958ed744 100644 --- a/src/calibre/gui2/book_details.py +++ b/src/calibre/gui2/book_details.py @@ -6,6 +6,7 @@ __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' from binascii import unhexlify +from functools import partial from PyQt5.Qt import (QPixmap, QSize, QWidget, Qt, pyqtSignal, QUrl, QIcon, QPropertyAnimation, QEasingCurve, QApplication, QFontInfo, QAction, @@ -107,6 +108,7 @@ class CoverView(QWidget): # {{{ cover_changed = pyqtSignal(object, object) cover_removed = pyqtSignal(object) + open_cover_with = pyqtSignal(object, object) def __init__(self, vertical, parent=None): QWidget.__init__(self, parent) @@ -203,6 +205,7 @@ class CoverView(QWidget): # {{{ ) def contextMenuEvent(self, ev): + from calibre.gui2.open_with import populate_menu cm = QMenu(self) paste = cm.addAction(_('Paste Cover')) copy = cm.addAction(_('Copy Cover')) @@ -214,8 +217,28 @@ class CoverView(QWidget): # {{{ paste.triggered.connect(self.paste_from_clipboard) remove.triggered.connect(self.remove_cover) gc.triggered.connect(self.generate_cover) + + m = QMenu(_('Open with...')) + populate_menu(m, self.open_with, 'jpeg') + if len(m.actions()) == 0: + cm.addAction(_('Open with...'), self.choose_open_with) + else: + m.addSeparator() + m.addAction(_('Choose other program...'), self.choose_open_with) + cm.addMenu(m) cm.exec_(ev.globalPos()) + def open_with(self, entry): + id_ = self.data.get('id', None) + if id_ is not None: + self.open_cover_with.emit(id_, entry) + + def choose_open_with(self): + from calibre.gui2.open_with import choose_program + entry = choose_program('jpeg', self) + if entry is not None: + self.open_with(entry) + def copy_to_clipboard(self): QApplication.instance().clipboard().setPixmap(self.pixmap) @@ -286,6 +309,7 @@ class BookInfo(QWebView): compare_format = pyqtSignal(int, object) copy_link = pyqtSignal(object) manage_author = pyqtSignal(object) + open_fmt_with = pyqtSignal(int, object, object) def __init__(self, vertical, parent=None): QWebView.__init__(self, parent) @@ -405,7 +429,16 @@ class BookInfo(QWebView): ac.current_fmt = (book_id, fmt) ac.setText(t) menu.addAction(ac) - + if not fmt.upper().startswith('ORIGINAL_'): + from calibre.gui2.open_with import populate_menu + m = QMenu(_('Open with...')) + populate_menu(m, partial(self.open_with, book_id, fmt), fmt) + if len(m.actions()) == 0: + menu.addAction(_('Open with...'), partial(self.choose_open_with, book_id, fmt)) + else: + m.addSeparator() + m.addAction(_('Choose other program...'), partial(self.choose_open_with, book_id, fmt)) + menu.addMenu(m) else: el = r.linkElement() author = el.toPlainText() if unicode(el.attribute('calibre-data')) == u'authors' else None @@ -425,6 +458,15 @@ class BookInfo(QWebView): if len(menu.actions()) > 0: menu.exec_(ev.globalPos()) + def open_with(self, book_id, fmt, entry): + self.open_fmt_with.emit(book_id, fmt, entry) + + def choose_open_with(self, book_id, fmt): + from calibre.gui2.open_with import choose_program + entry = choose_program(fmt, self) + if entry is not None: + self.open_with(book_id, fmt, entry) + # }}} @@ -531,9 +573,11 @@ class BookDetails(QWidget): # {{{ remote_file_dropped = pyqtSignal(object, object) files_dropped = pyqtSignal(object, object) cover_changed = pyqtSignal(object, object) + open_cover_with = pyqtSignal(object, object) cover_removed = pyqtSignal(object) view_device_book = pyqtSignal(object) manage_author = pyqtSignal(object) + open_fmt_with = pyqtSignal(int, object, object) # Drag 'n drop {{{ DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS @@ -590,12 +634,14 @@ class BookDetails(QWidget): # {{{ self.cover_view = CoverView(vertical, self) self.cover_view.cover_changed.connect(self.cover_changed.emit) + self.cover_view.open_cover_with.connect(self.open_cover_with.emit) self.cover_view.cover_removed.connect(self.cover_removed.emit) self._layout.addWidget(self.cover_view) self.book_info = BookInfo(vertical, self) self._layout.addWidget(self.book_info) self.book_info.link_clicked.connect(self.handle_click) self.book_info.remove_format.connect(self.remove_specific_format) + self.book_info.open_fmt_with.connect(self.open_fmt_with) self.book_info.save_format.connect(self.save_specific_format) self.book_info.restore_format.connect(self.restore_specific_format) self.book_info.compare_format.connect(self.compare_specific_format) @@ -640,4 +686,3 @@ class BookDetails(QWidget): # {{{ self.show_data(Metadata(_('Unknown'))) # }}} - diff --git a/src/calibre/gui2/init.py b/src/calibre/gui2/init.py index f84df9d587..70240c41ba 100644 --- a/src/calibre/gui2/init.py +++ b/src/calibre/gui2/init.py @@ -100,7 +100,7 @@ class LibraryViewMixin(object): # {{{ # }}} -class QuickviewSplitter(QSplitter): # {{{ +class QuickviewSplitter(QSplitter): # {{{ def __init__(self, parent=None, orientation=Qt.Vertical, qv_widget=None): QSplitter.__init__(self, parent=parent, orientation=orientation) @@ -489,6 +489,10 @@ class LayoutMixin(object): # {{{ self.book_details.files_dropped.connect(self.iactions['Add Books'].files_dropped_on_book) self.book_details.cover_changed.connect(self.bd_cover_changed, type=Qt.QueuedConnection) + self.book_details.open_cover_with.connect(self.bd_open_cover_with, + type=Qt.QueuedConnection) + self.book_details.open_fmt_with.connect(self.bd_open_fmt_with, + type=Qt.QueuedConnection) self.book_details.cover_removed.connect(self.bd_cover_removed, type=Qt.QueuedConnection) self.book_details.remote_file_dropped.connect( @@ -525,6 +529,18 @@ class LayoutMixin(object): # {{{ if self.cover_flow: self.cover_flow.dataChanged() + def bd_open_cover_with(self, book_id, entry): + cpath = self.current_db.new_api.format_abspath(book_id, '__COVER_INTERNAL__') + if cpath: + from calibre.gui2.open_with import run_program + run_program(entry, cpath, self) + + def bd_open_fmt_with(self, book_id, fmt, entry): + path = self.current_db.new_api.format_abspath(book_id, fmt) + if path: + from calibre.gui2.open_with import run_program + run_program(entry, path, self) + def bd_cover_removed(self, id_): self.library_view.model().db.remove_cover(id_, commit=True, notify=False) @@ -570,5 +586,3 @@ class LayoutMixin(object): # {{{ self.status_bar.update_state(library_total, total, current, selected) # }}} - - diff --git a/src/calibre/gui2/open_with.py b/src/calibre/gui2/open_with.py index 89dbd72f5e..419ab8cbeb 100644 --- a/src/calibre/gui2/open_with.py +++ b/src/calibre/gui2/open_with.py @@ -8,18 +8,59 @@ __copyright__ = '2015, Kovid Goyal ' import os, re, shlex, cPickle from collections import defaultdict +from threading import Thread +from functools import partial -from calibre import force_unicode, walk, guess_type, prints +from PyQt5.Qt import ( + QApplication, QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt, + QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon, QByteArray, + QBuffer, QPixmap) + +from calibre import force_unicode, walk, guess_type, prints, as_unicode from calibre.constants import iswindows, isosx, filesystem_encoding, cache_dir +from calibre.gui2 import error_dialog, choose_files +from calibre.gui2.widgets2 import Dialog +from calibre.gui2.progress_indicator import ProgressIndicator +from calibre.utils.config import JSONConfig from calibre.utils.icu import numeric_sort_key as sort_key from calibre.utils.localization import canonicalize_lang, get_lang +DESC_ROLE = Qt.UserRole +ENTRY_ROLE = DESC_ROLE + 1 + +def pixmap_to_data(pixmap): + ba = QByteArray() + buf = QBuffer(ba) + buf.open(QBuffer.WriteOnly) + pixmap.save(buf, 'PNG') + return bytearray(ba.data()) + +def run_program(entry, path, parent): + import subprocess + cmdline = entry_to_cmdline(entry, path) + print('Running Open With commandline:', repr(cmdline)) + try: + process = subprocess.Popen(cmdline) + except Exception as err: + return error_dialog( + parent, _('Failed to run'), _( + 'Failed to run program, click "Show Details" for more information'), + det_msg='Command line: %r\n%s' %(cmdline, as_unicode(err))) + t = Thread(name='WaitProgram', target=process.wait) + t.daemon = True + t.start() + if iswindows: - pass + oprefs = JSONConfig('windows_open_with') + run_program + def run_program(entry, path, parent): + raise NotImplementedError() + elif isosx: - pass + oprefs = JSONConfig('osx_open_with') else: - # Linux find_programs {{{ + oprefs = JSONConfig('xdg_open_with') + # XDG find_programs {{{ def parse_localized_key(key): name, rest = key.partition('[')[0::2] if not rest: @@ -42,6 +83,7 @@ else: return group = None ans = {} + ans['desktop_file_path'] = path for line in raw.splitlines(): m = gpat.match(line) if m is not None: @@ -207,25 +249,61 @@ else: if comment: comment += '\n' ans.setToolTip(comment + _('Command line:') + '\n' + (' '.join(entry['Exec']))) + + def choose_manually(filetype, parent): + ans = choose_files(parent, 'choose-open-with-program-manually', _('Choose a program to open %s files') % filetype.upper(), select_only_single_file=True) + if ans: + ans = ans[0] + if not os.access(ans, os.X_OK): + return error_dialog(parent, _('Cannot execute'), _( + 'The program %s is not an executable file') % ans, show=True) + return {'Exec':[ans, '%f'], 'Name':os.path.basename(ans)} + + def finalize_entry(entry): + icon_path = entry.get('Icon') + if icon_path: + ic = QIcon(icon_path) + if not ic.isNull(): + pmap = ic.pixmap(48, 48) + if not pmap.isNull(): + entry['icon_data'] = pixmap_to_data(pmap) + entry['MimeType'] = tuple(entry['MimeType']) + return entry + + def entry_sort_key(entry): + return sort_key(entry['Name']) + + def entry_to_icon_text(entry): + data = entry.get('icon_data') + if data is None: + icon = QIcon(I('blank.png')) + else: + pmap = QPixmap() + pmap.loadFromData(bytes(data)) + icon = QIcon(pmap) + return icon, entry['Name'] + + def entry_to_cmdline(entry, path): + path = os.path.abspath(path) + rmap = { + 'f':path, 'F':path, 'u':'file://'+path, 'U':'file://'+path, '%':'%', + 'i':entry.get('Icon', '%i'), 'c':entry.get('Name', ''), 'k':entry.get('desktop_file_path', ''), + } + def replace(match): + char = match.group()[-1] + repl = rmap.get(char) + return match.group() if repl is None else repl + sub = re.compile(r'%[fFuUdDnNickvm%]').sub + cmd = entry['Exec'] + return cmd[:1] + [sub(replace, x) for x in cmd[1:]] + # }}} -from threading import Thread - -from PyQt5.Qt import ( - QApplication, QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt, - QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon) -from calibre.gui2 import gprefs, error_dialog -from calibre.gui2.widgets2 import Dialog -from calibre.gui2.progress_indicator import ProgressIndicator - -DESC_ROLE = Qt.UserRole -ENTRY_ROLE = DESC_ROLE + 1 - -class ChooseProgram(Dialog): +class ChooseProgram(Dialog): # {{{ found = pyqtSignal() - def __init__(self, file_type='jpeg', parent=None, prefs=gprefs): + def __init__(self, file_type='jpeg', parent=None, prefs=oprefs): self.file_type = file_type self.programs = self.find_error = self.selected_entry = None self.select_manually = False @@ -258,6 +336,8 @@ class ChooseProgram(Dialog): l.addWidget(la), l.addWidget(pl) la.setBuddy(pl) + b = self.bb.addButton(_('&Browse computer for program'), self.bb.ActionRole) + b.clicked.connect(self.manual) l.addWidget(self.bb) def sizeHint(self): @@ -291,10 +371,36 @@ class ChooseProgram(Dialog): self.selected_entry = ci.data(ENTRY_ROLE) return Dialog.accept(self) + def manual(self): + self.select_manually = True + self.reject() + +oprefs.defaults['entries'] = {} + +def choose_program(file_type='jpeg', parent=None, prefs=oprefs): + d = ChooseProgram(file_type, parent, prefs) + d.exec_() + entry = choose_manually(file_type, parent) if d.select_manually else d.selected_entry + if entry is not None: + entry = finalize_entry(entry) + entries = oprefs['entries'] + if file_type not in entries: + entries[file_type] = [] + entries[file_type].append(entry) + entries[file_type].sort(key=entry_sort_key) + oprefs['entries'] = entries + return entry + +def populate_menu(menu, receiver, file_type): + for entry in oprefs['entries'].get(file_type, ()): + ac = menu.addAction(*entry_to_icon_text(entry)) + ac.triggered.connect(partial(receiver, entry)) + return menu + +# }}} + if __name__ == '__main__': from pprint import pprint app = QApplication([]) - d = ChooseProgram() - d.exec_() - pprint(d.selected_entry) + pprint(choose_program('pdf')) del app