/src/qphotoupload/ui.py
Python | 618 lines | 589 code | 21 blank | 8 comment | 15 complexity | 4a105818a7a4caaf619ecdcd04109989 MD5 | raw file
- # (c) Nguyễn Hồng Quân <ng.hong.quan@gmail.com>
- import os.path
- import imghdr
- from urllib.parse import urlsplit, unquote
- import logging
- import re
- import glob
- import math
- import textwrap
- from timeit import default_timer as timer
- from gi.repository import GLib, GObject, Gtk, Pango, Gio, Gdk
- from gi.repository.GdkPixbuf import Pixbuf
- from gi.repository import WebKit
- import cairo
- from . import env
- from .util import get_ui_file, get_app_icon_list, get_main_icon_file
- #===== Constants ====#
- MAXCHAR = 16
- COL_AC_SERVICE = 0
- COL_AC_TOKEN = 1
- COL_AC_USERID = 2
- COL_AC_EMAIL = 3
- COL_AL_ID = 0
- COL_AL_NAME = 1
- COL_AL_COUNT = 2
- COL_FI_PATH = 0
- COL_FI_NAME = 1
- COL_FI_PIXBUF = 2
- COL_FI_DESCR = 3
- COL_FI_PROGR = 4
- SIZE_THUMB = 120
- RADIUS_THUMB = 4
- thumb_tooltip_tpl = '''{path}
- <i><b>Description</b> (Right-click to edit):</i>
- {desc}'''
- #===== Classes =====#
- class UIFactory():
- ''' Allow to retrieve GUI elements as object attributes '''
- def __init__(self, uifile):
- self.builder = Gtk.Builder()
- self.builder.add_from_file(uifile)
- self._pending_handlers = []
- def __getattr__(self, name):
- ''' Allow to get UI elements as from dictionary/object attributes '''
- return self.builder.get_object(name)
- # This method must be called only once.
- def connect_handlers(self, handlers):
- handlers.extend(self._pending_handlers)
- handlersdict = dict((h.__name__, h) for h in handlers)
- self.builder.connect_signals(handlersdict)
- class QPhotoUploadUi(UIFactory):
- _current_path = None
- def __init__(self, app):
- uifile = get_ui_file('main')
- super(self.__class__, self).__init__(uifile)
- self.app = app
- self._pending_handlers.extend(get_all_handlers(self))
- self.combo_account.set_cell_data_func(self.cell_logo,
- self.set_service_icon, None)
- self.setup_thumbnail_view()
- self.set_logo_and_icons()
- self.accelgroup_quit.connect(ord('q'), Gdk.ModifierType.CONTROL_MASK,
- Gtk.AccelFlags.LOCKED,
- self.app.quit)
- def setup_thumbnail_view(self):
- view = self.view_photo
- #view.set_tooltip_column(COL_FI_PATH)
- view.set_text_column(COL_FI_NAME)
- view.set_pixbuf_column(COL_FI_PIXBUF)
- # Drag 'n' Drop support
- target_list = Gtk.TargetList.new([])
- target_list.add_uri_targets(0) # Arbitrary number?
- view.enable_model_drag_dest([], Gdk.DragAction.COPY)
- view.drag_dest_add_uri_targets()
- # Set ellipsis mode
- cellarea = self.view_photo.get_property('cell-area')
- cell_progress = Gtk.CellRendererProgress()
- cell_progress.set_fixed_size(-1, 4)
- cell_progress.set_property('text', '') # Don't show text
- cellarea.pack_start(cell_progress, False, True, True)
- for c in cellarea.get_cells():
- if type(c) is Gtk.CellRendererText:
- c.set_property('ellipsize', Pango.EllipsizeMode.MIDDLE)
- cellarea.set_cell_data_func(cell_progress, self.set_progress_props, None)
- def set_progress_props(self, layout, cell, model, itr, userdata=None):
- progress = model.get_value(itr, COL_FI_PROGR)
- if progress > 0:
- cell.set_visible(True)
- cell.set_property('value', min(100, progress))
- else:
- cell.set_visible(False)
- def set_logo_and_icons(self):
- self.window.set_icon_list([Pixbuf.new_from_file(f)
- for f in get_app_icon_list()])
- logo = Pixbuf.new_from_file(get_main_icon_file())
- self.diag_about.set_logo(logo)
- self.diag_about.set_version(env.__version__)
- def on_btn_quit_clicked(self, button):
- self.app.quit()
- def on_btn_add_album_clicked(self, button):
- combo = self.combo_album
- box = self.box_album
- # Create a text entry for user to
- # put new album name
- if combo in box.get_children():
- entry = Gtk.Entry()
- box.remove(combo)
- box.pack_start(entry, True, True, 0)
- entry.show()
- entry.grab_focus()
- button.set_icon_name('object-select-symbolic')
- self.btn_del_album.set_icon_name('mail-replied-symbolic')
- else:
- # Text entry exists already
- entry = box.get_children()[0]
- name = entry.get_text().strip()
- if not name:
- return
- combo = self.combo_account
- idx = combo.get_active()
- if idx == -1:
- return
- model = combo.get_model()
- service = model[idx][COL_AC_SERVICE]
- access_token = model[idx][COL_AC_TOKEN]
- button.set_icon_name('list-add-symbolic')
- self.btn_del_album.set_icon_name('list-remove-symbolic')
- self.app.add_album(name, service, access_token)
- entry.destroy()
- box.add(self.combo_album)
- def on_btn_del_album_clicked(self, button):
- combo = self.combo_album
- box = self.box_album
- if combo not in box.get_children():
- # Is in "add album" mode, cancel
- box.get_children()[0].destroy()
- self.btn_add_album.set_icon_name('list-add-symbolic')
- button.set_icon_name('list-remove-symbolic')
- box.pack_start(combo, True, True, 0)
- else:
- # Normal mode
- itr = combo.get_active_iter()
- idx = self.combo_account.get_active()
- if not itr or idx == -1:
- return
- aid = int(self.store_albums.get_value(itr, COL_AL_ID))
- service = self.store_accounts[idx][COL_AC_SERVICE]
- access_token = self.store_accounts[idx][COL_AC_TOKEN]
- self.app.delete_album(aid, service, access_token)
- def on_view_photo_drag_data_received(self, widget, drag_context,
- x, y, sel_data, info, time):
- uris = sel_data.get_uris()
- files = []
- for uri in uris:
- p = urlsplit(uri)
- # Ignore non-local files
- if p.scheme != '' and p.scheme != 'file':
- continue
- p = unquote(p.path)
- if os.path.isfile(p):
- files.append(p)
- elif os.path.isdir(p):
- files.extend(get_files_under(p))
- self.add_files(files)
- def on_combo_account_changed(self, combo):
- ''' Get list of albums when user choose an account'''
- idx = combo.get_active()
- if idx == -1:
- self.store_accounts.clear()
- return
- service, access_token, userid, email = self.store_accounts[idx]
- Gtk.main_iteration()
- self.app.load_albums(service, access_token, userid)
- def on_btn_add_account_clicked(self, button):
- aadiag = AddAccountDialog(self.window)
- # Launch web server to take callback
- self.app.run_server()
- resp = aadiag.run()
- aadiag.hide()
- if resp != Gtk.ResponseType.OK:
- aadiag.destroy()
- # Off server
- self.app.stop_server()
- return
- # else
- idx = aadiag.combox.get_active()
- if idx == -1:
- # Off server
- self.app.stop_server()
- return
- service = aadiag.services[idx][1]
- aadiag.destroy()
- self.app.add_account(service)
- def on_btn_del_account_clicked(self, button):
- itr = self.combo_account.get_active_iter()
- if not itr:
- return
- # Remove in database
- service = self.store_accounts.get_value(itr, COL_AC_SERVICE)
- uid = self.store_accounts.get_value(itr, COL_AC_USERID)
- self.app.remove_account(service, uid)
- # Remove in UI
- self.combo_account.set_model(None)
- # Have to disconnect model from combobox before
- # removing, or the model will be cleared out
- self.store_accounts.remove(itr)
- self.combo_account.set_model(self.store_accounts)
- # Clear albums
- self.combo_album.set_model(None)
- self.store_albums.clear()
- self.combo_album.set_model(self.store_albums)
- def on_btn_upload_clicked(self, button):
- idx_account = self.combo_account.get_active()
- if idx_account == -1:
- return
- idx_album = self.combo_album.get_active()
- if idx_album == -1:
- return
- if not len(self.store_thumbnails):
- return
- button.set_sensitive(False)
- service, access_token, userid, email = self.store_accounts[idx_account]
- album_id = self.store_albums[idx_album][COL_AL_ID]
- self.app.upload(service, access_token, album_id)
- def on_btn_add_file_clicked(self, button):
- resp = self.chooser.run()
- self.chooser.hide()
- if resp != Gtk.ResponseType.OK:
- return
- uris = self.chooser.get_uris()
- self.add_files(uris)
- def on_btn_del_file_clicked(self, button):
- model = self.store_thumbnails
- iters = (model.get_iter(p) for p in self.view_photo.get_selected_items())
- # Disconnect model before removing item
- self.view_photo.set_model(None)
- [model.remove(i) for i in iters]
- self._current_path = None
- self.view_photo.set_model(model)
- def on_btn_clear_file_clicked(self, button):
- self.store_thumbnails.clear()
- self._current_path = None
- def on_view_photo_query_tooltip(self, iconview, x, y, keyboard_mode, tooltip):
- show, x, y, model, path, itr = iconview.get_tooltip_context(x, y, keyboard_mode)
- if show:
- filepath = model.get_value(itr, COL_FI_PATH)
- filepath = '\n'.join(textwrap.wrap(filepath, 40))
- desc = model.get_value(itr, COL_FI_DESCR)
- desc = '\n'.join(textwrap.wrap(desc, 40))
- tooltip.set_markup(thumb_tooltip_tpl.format(path=filepath, desc=desc))
- return show
- def on_view_photo_button_press_event(self, iconview, event):
- if event.type != Gdk.EventType.BUTTON_PRESS \
- or event.button != Gdk.BUTTON_SECONDARY:
- # Ignore
- return False
- path = iconview.get_path_at_pos(event.x, event.y)
- if path is None:
- # Ignore
- return False
- self._current_path = path
- self.menu_thumbview.popup(None, None, None, None,
- event.button, event.time)
- return True
- def on_view_photo_key_press_event(self, iconview, event):
- # Note: The iconview must "can-focus" for this handler to work
- if event.keyval == Gdk.KEY_Escape:
- iconview.unselect_all()
- elif event.keyval == Gdk.KEY_Delete:
- selected = iconview.get_selected_items()
- itrs = [self.store_thumbnails.get_iter(p) for p in selected]
- for i in itrs:
- self.store_thumbnails.remove(i)
- def on_mitem_desc_activate(self, mitem):
- ''' User activate 'Edit description' menu item '''
- if not self._current_path:
- return
- path = self._current_path
- desc = self.store_thumbnails[path][COL_FI_DESCR]
- filepath = self.store_thumbnails[path][COL_FI_PATH]
- self.label_filename.set_text(os.path.basename(filepath))
- buff = self.textview_desc.get_buffer()
- buff.set_text(desc)
- resp = self.diag_edit_desc.run()
- self.diag_edit_desc.hide()
- if not resp:
- return
- text = buff.get_text(buff.get_start_iter(), buff.get_end_iter(), True)
- self.store_thumbnails[path][COL_FI_DESCR] = text
- def on_btn_about_clicked(self, button):
- self.diag_about.run()
- self.diag_about.hide()
- def set_service_icon(self, layout, cell, model, itr, userdata=None):
- service = model.get_value(itr, COL_AC_SERVICE)
- pix = get_service_pixbuf(service)
- if pix:
- cell.set_property('pixbuf', pix)
- else:
- cell.set_property('icon-name', 'applications-internet')
- def add_files(self, files):
- existing = tuple(f[COL_FI_PATH] for f in self.store_thumbnails)
- for uri in files:
- if isinstance(uri, Gio.File):
- self.add_file_as_gio_file(uri)
- continue
- if '://' not in uri or uri.startswith('file://'):
- path = uri_to_path(uri)
- if os.path.isfile(path) and path not in existing:
- self.add_file_by_path(path)
- elif uri not in existing:
- # URI may be gphoto2://
- self.add_file_by_uri(uri)
- def add_file_by_path(self, path):
- try:
- pix = Pixbuf.new_from_file_at_size(path, SIZE_THUMB, SIZE_THUMB)
- except Exception:
- return
- rounded = round_pixbuf(pix, RADIUS_THUMB)
- self.store_thumbnails.append((path, os.path.basename(path),
- rounded, '', 0))
- def add_file_by_uri(self, uri):
- # With non-local file, we must use Gio
- gfile = Gio.file_new_for_uri(uri)
- # Use async version to avoid blocking UI
- gfile.read_async(GLib.PRIORITY_DEFAULT, None, self.cb_gfile_read, None)
- def add_file_as_gio_file(self, gfile):
- ftype = gfile.query_file_type(Gio.FileQueryInfoFlags.NONE, None)
- if ftype == Gio.FileType.REGULAR:
- gfile.read_async(GLib.PRIORITY_DEFAULT, None, self.cb_gfile_read, None)
- elif ftype == Gio.FileType.DIRECTORY:
- children = gfile.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME,
- Gio.FileQueryInfoFlags.NONE, None)
- while True:
- # TODO: Use next_files version
- info = children.next_file(None)
- if not info:
- break
- child = children.get_child(info)
- child.read_async(GLib.PRIORITY_DEFAULT, None, self.cb_gfile_read, None)
- children.close_async(GLib.PRIORITY_DEFAULT, None, None, None)
- def cb_gfile_read(self, gfile, res, userdata=None):
- try:
- stream = gfile.read_finish(res)
- except Exception:
- return
- # Use async version to avoid blocking UI
- Pixbuf.new_from_stream_at_scale_async(stream, SIZE_THUMB, SIZE_THUMB, True,
- None, self.cb_pixbuf_read, gfile)
- def cb_pixbuf_read(self, stream, res, userdata):
- try:
- pix = Pixbuf.new_from_stream_finish(res)
- except Exception:
- return
- if not pix:
- return
- rounded = round_pixbuf(pix, RADIUS_THUMB)
- gfile = userdata
- self.store_thumbnails.append((gfile.get_uri(), gfile.get_basename(),
- rounded, '', 0))
- # Use async version to avoid blocking UI
- stream.close_async(GLib.PRIORITY_DEFAULT, None, None, None)
- def login_to_oauth(self, url, url_checker):
- webdiag = AuthDialog(self.window, url, url_checker)
- resp = webdiag.run()
- webdiag.destroy()
- # Turn server off
- self.app.stop_server()
- if resp == Gtk.ResponseType.OK:
- return True
- return False
- def get_photo_paths(self):
- return ((r[COL_FI_PATH], r[COL_FI_DESCR]) for r in self.store_thumbnails)
- def make_album_selected(self, aid):
- for i, row in enumerate(self.store_albums):
- if row[COL_AL_ID] == str(aid):
- self.combo_album.set_active(i)
- break
- def set_progress(self, idx, value):
- model = self.store_thumbnails
- itr = model.get_iter(idx)
- if itr:
- model.set_value(itr, COL_FI_PROGR, value)
- def reset_all_progress_bars(self):
- for row in self.store_thumbnails:
- if row[COL_FI_PROGR]:
- row[COL_FI_PROGR] = 0
- self.bar_upload.set_fraction(0)
- def get_shrink_size(self):
- text = self.combo_size.get_active_text()
- if not text or 'x' not in text:
- return None
- w, h = text.split('x')
- return int(w)
- def wait_load_albums(self):
- grid = self.toolgrid
- grid.remove(self.toolbar_album)
- spinner = Gtk.Spinner()
- grid.attach(spinner, 2, 1, 1, 1)
- # Make spinner look smaller
- spinner.set_valign(Gtk.Align.CENTER)
- spinner.show()
- spinner.start()
- def unwait_load_albums(self):
- grid = self.toolgrid
- spinner = grid.get_child_at(2, 1)
- spinner.stop()
- grid.remove(spinner)
- grid.attach(self.toolbar_album, 2, 1, 1, 1)
- class AddAccountDialog(Gtk.Dialog):
- def __init__(self, parent):
- super(AddAccountDialog, self).__init__(
- 'Add account', parent, 0,
- (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
- Gtk.STOCK_ADD, Gtk.ResponseType.OK))
- self.services = Gtk.ListStore(Pixbuf, str)
- self.services.append((get_service_pixbuf('facebook'), 'facebook'))
- self.services.append((get_service_pixbuf('google'), 'google'))
- self.combox = combox = Gtk.ComboBox.new_with_model(self.services)
- cpix = Gtk.CellRendererPixbuf()
- combox.pack_start(cpix, False)
- combox.add_attribute(cpix, 'pixbuf', 0)
- cname = Gtk.CellRendererText()
- combox.pack_start(cname, True)
- combox.add_attribute(cname, 'text', 1)
- maingrid = Gtk.Grid()
- maingrid.set_border_width(4)
- maingrid.set_column_spacing(4)
- maingrid.attach(Gtk.Label('Service'), 0, 0, 1, 1)
- maingrid.attach(combox, 1, 0, 1, 1)
- area = self.get_content_area()
- area.pack_start(maingrid, True, True, 0)
- maingrid.show_all()
- class AuthDialog(Gtk.Dialog):
- ''' Dialog to show Facebook authentication web page'''
- def __init__(self, parent, url, url_checker):
- '''
- url_checker: Function to check URL and stop the dialog if URL satisfies.
- '''
- super(AuthDialog, self).__init__('Please login...', parent=parent)
- self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
- self.webview = webview = WebKit.WebView()
- webview.load_uri(url)
- self.check_url_to_return = url_checker
- webview.connect('resource-load-finished', self.check_uri)
- webview.set_vexpand(True)
- cta = self.get_content_area()
- cta.add(webview)
- self.set_default_size(500, 400)
- cta.show_all()
- def check_uri(self, web_view, web_frame, web_resource, user_data=None):
- if self.check_url_to_return(web_view.get_uri()):
- self.response(Gtk.ResponseType.OK)
- #===== Util functions =====#
- def uri_to_path(uri):
- p = urlsplit(uri)
- return unquote(p.path)
- def mid_ellipsis(strg, length=MAXCHAR):
- if len(strg) > length:
- mid = int(length/2)
- return strg[:mid] + '…' + strg[-(length-mid-1):]
- else:
- return strg
- def get_service_pixbuf(service):
- iconfolder = os.path.join(env.get_data_dir(), 'icons')
- filepath = os.path.join(iconfolder, service + '.png')
- if os.path.exists(filepath):
- return Pixbuf.new_from_file_at_size(filepath, 16, 16)
- return None
- def get_all_handlers(obj):
- return (getattr(obj, m) for m in dir(obj)
- if m.startswith('on_'))
- def get_files_under(folder):
- return glob.iglob(os.path.join(folder, '*'))
- def cairo_rounded_box(cairo_ctx, x, y, width, height, radius):
- cairo_ctx.new_sub_path()
- cairo_quater_arc(cairo_ctx, x+radius, y+radius, radius, 3)
- cairo_quater_arc(cairo_ctx, x+width-radius, y+radius, radius, 4)
- cairo_quater_arc(cairo_ctx, x+width-radius, y+height-radius, radius, 1)
- cairo_quater_arc(cairo_ctx, x+radius, y+height-radius, radius, 2)
- def cairo_quater_arc(cairo_ctx, xc, yc, radius, quater):
- assert type(quater) == int and quater > 0
- if radius <= 0.0:
- cairo_ctx.line_to(xc, yc)
- return
- cairo_ctx.save()
- cairo_ctx.translate(xc, yc)
- start, end = (quater - 1)*math.pi/2, quater*math.pi/2
- cairo_ctx.arc(0, 0, radius, start, end)
- cairo_ctx.restore()
- def round_pixbuf(pix, radius):
- width, height = pix.get_width(), pix.get_height()
- surface = cairo.ImageSurface(cairo.FORMAT_ARGB32,
- width, height)
- cr = cairo.Context(surface)
- Gdk.cairo_set_source_pixbuf(cr, pix, 0, 0)
- cairo_rounded_box(cr, 0, 0, width, height, radius)
- cr.clip()
- cr.paint()
- #Ref: http://whyareyoureadingthisurl.wordpress.com/2012/01/09/cairo-contextsurface-to-gdk-pixbuf/
- return Gdk.pixbuf_get_from_surface(surface, 0, 0,
- surface.get_width(),
- surface.get_height());
- # Ref: https://gist.github.com/bert/985903