PageRenderTime 494ms CodeModel.GetById 19ms RepoModel.GetById 1ms app.codeStats 0ms

/src/qphotoupload/ui.py

https://bitbucket.org/hongquan/qphotoupload
Python | 618 lines | 589 code | 21 blank | 8 comment | 15 complexity | 4a105818a7a4caaf619ecdcd04109989 MD5 | raw file
  1. # (c) Nguyễn Hồng Quân <ng.hong.quan@gmail.com>
  2. import os.path
  3. import imghdr
  4. from urllib.parse import urlsplit, unquote
  5. import logging
  6. import re
  7. import glob
  8. import math
  9. import textwrap
  10. from timeit import default_timer as timer
  11. from gi.repository import GLib, GObject, Gtk, Pango, Gio, Gdk
  12. from gi.repository.GdkPixbuf import Pixbuf
  13. from gi.repository import WebKit
  14. import cairo
  15. from . import env
  16. from .util import get_ui_file, get_app_icon_list, get_main_icon_file
  17. #===== Constants ====#
  18. MAXCHAR = 16
  19. COL_AC_SERVICE = 0
  20. COL_AC_TOKEN = 1
  21. COL_AC_USERID = 2
  22. COL_AC_EMAIL = 3
  23. COL_AL_ID = 0
  24. COL_AL_NAME = 1
  25. COL_AL_COUNT = 2
  26. COL_FI_PATH = 0
  27. COL_FI_NAME = 1
  28. COL_FI_PIXBUF = 2
  29. COL_FI_DESCR = 3
  30. COL_FI_PROGR = 4
  31. SIZE_THUMB = 120
  32. RADIUS_THUMB = 4
  33. thumb_tooltip_tpl = '''{path}
  34. <i><b>Description</b> (Right-click to edit):</i>
  35. {desc}'''
  36. #===== Classes =====#
  37. class UIFactory():
  38. ''' Allow to retrieve GUI elements as object attributes '''
  39. def __init__(self, uifile):
  40. self.builder = Gtk.Builder()
  41. self.builder.add_from_file(uifile)
  42. self._pending_handlers = []
  43. def __getattr__(self, name):
  44. ''' Allow to get UI elements as from dictionary/object attributes '''
  45. return self.builder.get_object(name)
  46. # This method must be called only once.
  47. def connect_handlers(self, handlers):
  48. handlers.extend(self._pending_handlers)
  49. handlersdict = dict((h.__name__, h) for h in handlers)
  50. self.builder.connect_signals(handlersdict)
  51. class QPhotoUploadUi(UIFactory):
  52. _current_path = None
  53. def __init__(self, app):
  54. uifile = get_ui_file('main')
  55. super(self.__class__, self).__init__(uifile)
  56. self.app = app
  57. self._pending_handlers.extend(get_all_handlers(self))
  58. self.combo_account.set_cell_data_func(self.cell_logo,
  59. self.set_service_icon, None)
  60. self.setup_thumbnail_view()
  61. self.set_logo_and_icons()
  62. self.accelgroup_quit.connect(ord('q'), Gdk.ModifierType.CONTROL_MASK,
  63. Gtk.AccelFlags.LOCKED,
  64. self.app.quit)
  65. def setup_thumbnail_view(self):
  66. view = self.view_photo
  67. #view.set_tooltip_column(COL_FI_PATH)
  68. view.set_text_column(COL_FI_NAME)
  69. view.set_pixbuf_column(COL_FI_PIXBUF)
  70. # Drag 'n' Drop support
  71. target_list = Gtk.TargetList.new([])
  72. target_list.add_uri_targets(0) # Arbitrary number?
  73. view.enable_model_drag_dest([], Gdk.DragAction.COPY)
  74. view.drag_dest_add_uri_targets()
  75. # Set ellipsis mode
  76. cellarea = self.view_photo.get_property('cell-area')
  77. cell_progress = Gtk.CellRendererProgress()
  78. cell_progress.set_fixed_size(-1, 4)
  79. cell_progress.set_property('text', '') # Don't show text
  80. cellarea.pack_start(cell_progress, False, True, True)
  81. for c in cellarea.get_cells():
  82. if type(c) is Gtk.CellRendererText:
  83. c.set_property('ellipsize', Pango.EllipsizeMode.MIDDLE)
  84. cellarea.set_cell_data_func(cell_progress, self.set_progress_props, None)
  85. def set_progress_props(self, layout, cell, model, itr, userdata=None):
  86. progress = model.get_value(itr, COL_FI_PROGR)
  87. if progress > 0:
  88. cell.set_visible(True)
  89. cell.set_property('value', min(100, progress))
  90. else:
  91. cell.set_visible(False)
  92. def set_logo_and_icons(self):
  93. self.window.set_icon_list([Pixbuf.new_from_file(f)
  94. for f in get_app_icon_list()])
  95. logo = Pixbuf.new_from_file(get_main_icon_file())
  96. self.diag_about.set_logo(logo)
  97. self.diag_about.set_version(env.__version__)
  98. def on_btn_quit_clicked(self, button):
  99. self.app.quit()
  100. def on_btn_add_album_clicked(self, button):
  101. combo = self.combo_album
  102. box = self.box_album
  103. # Create a text entry for user to
  104. # put new album name
  105. if combo in box.get_children():
  106. entry = Gtk.Entry()
  107. box.remove(combo)
  108. box.pack_start(entry, True, True, 0)
  109. entry.show()
  110. entry.grab_focus()
  111. button.set_icon_name('object-select-symbolic')
  112. self.btn_del_album.set_icon_name('mail-replied-symbolic')
  113. else:
  114. # Text entry exists already
  115. entry = box.get_children()[0]
  116. name = entry.get_text().strip()
  117. if not name:
  118. return
  119. combo = self.combo_account
  120. idx = combo.get_active()
  121. if idx == -1:
  122. return
  123. model = combo.get_model()
  124. service = model[idx][COL_AC_SERVICE]
  125. access_token = model[idx][COL_AC_TOKEN]
  126. button.set_icon_name('list-add-symbolic')
  127. self.btn_del_album.set_icon_name('list-remove-symbolic')
  128. self.app.add_album(name, service, access_token)
  129. entry.destroy()
  130. box.add(self.combo_album)
  131. def on_btn_del_album_clicked(self, button):
  132. combo = self.combo_album
  133. box = self.box_album
  134. if combo not in box.get_children():
  135. # Is in "add album" mode, cancel
  136. box.get_children()[0].destroy()
  137. self.btn_add_album.set_icon_name('list-add-symbolic')
  138. button.set_icon_name('list-remove-symbolic')
  139. box.pack_start(combo, True, True, 0)
  140. else:
  141. # Normal mode
  142. itr = combo.get_active_iter()
  143. idx = self.combo_account.get_active()
  144. if not itr or idx == -1:
  145. return
  146. aid = int(self.store_albums.get_value(itr, COL_AL_ID))
  147. service = self.store_accounts[idx][COL_AC_SERVICE]
  148. access_token = self.store_accounts[idx][COL_AC_TOKEN]
  149. self.app.delete_album(aid, service, access_token)
  150. def on_view_photo_drag_data_received(self, widget, drag_context,
  151. x, y, sel_data, info, time):
  152. uris = sel_data.get_uris()
  153. files = []
  154. for uri in uris:
  155. p = urlsplit(uri)
  156. # Ignore non-local files
  157. if p.scheme != '' and p.scheme != 'file':
  158. continue
  159. p = unquote(p.path)
  160. if os.path.isfile(p):
  161. files.append(p)
  162. elif os.path.isdir(p):
  163. files.extend(get_files_under(p))
  164. self.add_files(files)
  165. def on_combo_account_changed(self, combo):
  166. ''' Get list of albums when user choose an account'''
  167. idx = combo.get_active()
  168. if idx == -1:
  169. self.store_accounts.clear()
  170. return
  171. service, access_token, userid, email = self.store_accounts[idx]
  172. Gtk.main_iteration()
  173. self.app.load_albums(service, access_token, userid)
  174. def on_btn_add_account_clicked(self, button):
  175. aadiag = AddAccountDialog(self.window)
  176. # Launch web server to take callback
  177. self.app.run_server()
  178. resp = aadiag.run()
  179. aadiag.hide()
  180. if resp != Gtk.ResponseType.OK:
  181. aadiag.destroy()
  182. # Off server
  183. self.app.stop_server()
  184. return
  185. # else
  186. idx = aadiag.combox.get_active()
  187. if idx == -1:
  188. # Off server
  189. self.app.stop_server()
  190. return
  191. service = aadiag.services[idx][1]
  192. aadiag.destroy()
  193. self.app.add_account(service)
  194. def on_btn_del_account_clicked(self, button):
  195. itr = self.combo_account.get_active_iter()
  196. if not itr:
  197. return
  198. # Remove in database
  199. service = self.store_accounts.get_value(itr, COL_AC_SERVICE)
  200. uid = self.store_accounts.get_value(itr, COL_AC_USERID)
  201. self.app.remove_account(service, uid)
  202. # Remove in UI
  203. self.combo_account.set_model(None)
  204. # Have to disconnect model from combobox before
  205. # removing, or the model will be cleared out
  206. self.store_accounts.remove(itr)
  207. self.combo_account.set_model(self.store_accounts)
  208. # Clear albums
  209. self.combo_album.set_model(None)
  210. self.store_albums.clear()
  211. self.combo_album.set_model(self.store_albums)
  212. def on_btn_upload_clicked(self, button):
  213. idx_account = self.combo_account.get_active()
  214. if idx_account == -1:
  215. return
  216. idx_album = self.combo_album.get_active()
  217. if idx_album == -1:
  218. return
  219. if not len(self.store_thumbnails):
  220. return
  221. button.set_sensitive(False)
  222. service, access_token, userid, email = self.store_accounts[idx_account]
  223. album_id = self.store_albums[idx_album][COL_AL_ID]
  224. self.app.upload(service, access_token, album_id)
  225. def on_btn_add_file_clicked(self, button):
  226. resp = self.chooser.run()
  227. self.chooser.hide()
  228. if resp != Gtk.ResponseType.OK:
  229. return
  230. uris = self.chooser.get_uris()
  231. self.add_files(uris)
  232. def on_btn_del_file_clicked(self, button):
  233. model = self.store_thumbnails
  234. iters = (model.get_iter(p) for p in self.view_photo.get_selected_items())
  235. # Disconnect model before removing item
  236. self.view_photo.set_model(None)
  237. [model.remove(i) for i in iters]
  238. self._current_path = None
  239. self.view_photo.set_model(model)
  240. def on_btn_clear_file_clicked(self, button):
  241. self.store_thumbnails.clear()
  242. self._current_path = None
  243. def on_view_photo_query_tooltip(self, iconview, x, y, keyboard_mode, tooltip):
  244. show, x, y, model, path, itr = iconview.get_tooltip_context(x, y, keyboard_mode)
  245. if show:
  246. filepath = model.get_value(itr, COL_FI_PATH)
  247. filepath = '\n'.join(textwrap.wrap(filepath, 40))
  248. desc = model.get_value(itr, COL_FI_DESCR)
  249. desc = '\n'.join(textwrap.wrap(desc, 40))
  250. tooltip.set_markup(thumb_tooltip_tpl.format(path=filepath, desc=desc))
  251. return show
  252. def on_view_photo_button_press_event(self, iconview, event):
  253. if event.type != Gdk.EventType.BUTTON_PRESS \
  254. or event.button != Gdk.BUTTON_SECONDARY:
  255. # Ignore
  256. return False
  257. path = iconview.get_path_at_pos(event.x, event.y)
  258. if path is None:
  259. # Ignore
  260. return False
  261. self._current_path = path
  262. self.menu_thumbview.popup(None, None, None, None,
  263. event.button, event.time)
  264. return True
  265. def on_view_photo_key_press_event(self, iconview, event):
  266. # Note: The iconview must "can-focus" for this handler to work
  267. if event.keyval == Gdk.KEY_Escape:
  268. iconview.unselect_all()
  269. elif event.keyval == Gdk.KEY_Delete:
  270. selected = iconview.get_selected_items()
  271. itrs = [self.store_thumbnails.get_iter(p) for p in selected]
  272. for i in itrs:
  273. self.store_thumbnails.remove(i)
  274. def on_mitem_desc_activate(self, mitem):
  275. ''' User activate 'Edit description' menu item '''
  276. if not self._current_path:
  277. return
  278. path = self._current_path
  279. desc = self.store_thumbnails[path][COL_FI_DESCR]
  280. filepath = self.store_thumbnails[path][COL_FI_PATH]
  281. self.label_filename.set_text(os.path.basename(filepath))
  282. buff = self.textview_desc.get_buffer()
  283. buff.set_text(desc)
  284. resp = self.diag_edit_desc.run()
  285. self.diag_edit_desc.hide()
  286. if not resp:
  287. return
  288. text = buff.get_text(buff.get_start_iter(), buff.get_end_iter(), True)
  289. self.store_thumbnails[path][COL_FI_DESCR] = text
  290. def on_btn_about_clicked(self, button):
  291. self.diag_about.run()
  292. self.diag_about.hide()
  293. def set_service_icon(self, layout, cell, model, itr, userdata=None):
  294. service = model.get_value(itr, COL_AC_SERVICE)
  295. pix = get_service_pixbuf(service)
  296. if pix:
  297. cell.set_property('pixbuf', pix)
  298. else:
  299. cell.set_property('icon-name', 'applications-internet')
  300. def add_files(self, files):
  301. existing = tuple(f[COL_FI_PATH] for f in self.store_thumbnails)
  302. for uri in files:
  303. if isinstance(uri, Gio.File):
  304. self.add_file_as_gio_file(uri)
  305. continue
  306. if '://' not in uri or uri.startswith('file://'):
  307. path = uri_to_path(uri)
  308. if os.path.isfile(path) and path not in existing:
  309. self.add_file_by_path(path)
  310. elif uri not in existing:
  311. # URI may be gphoto2://
  312. self.add_file_by_uri(uri)
  313. def add_file_by_path(self, path):
  314. try:
  315. pix = Pixbuf.new_from_file_at_size(path, SIZE_THUMB, SIZE_THUMB)
  316. except Exception:
  317. return
  318. rounded = round_pixbuf(pix, RADIUS_THUMB)
  319. self.store_thumbnails.append((path, os.path.basename(path),
  320. rounded, '', 0))
  321. def add_file_by_uri(self, uri):
  322. # With non-local file, we must use Gio
  323. gfile = Gio.file_new_for_uri(uri)
  324. # Use async version to avoid blocking UI
  325. gfile.read_async(GLib.PRIORITY_DEFAULT, None, self.cb_gfile_read, None)
  326. def add_file_as_gio_file(self, gfile):
  327. ftype = gfile.query_file_type(Gio.FileQueryInfoFlags.NONE, None)
  328. if ftype == Gio.FileType.REGULAR:
  329. gfile.read_async(GLib.PRIORITY_DEFAULT, None, self.cb_gfile_read, None)
  330. elif ftype == Gio.FileType.DIRECTORY:
  331. children = gfile.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME,
  332. Gio.FileQueryInfoFlags.NONE, None)
  333. while True:
  334. # TODO: Use next_files version
  335. info = children.next_file(None)
  336. if not info:
  337. break
  338. child = children.get_child(info)
  339. child.read_async(GLib.PRIORITY_DEFAULT, None, self.cb_gfile_read, None)
  340. children.close_async(GLib.PRIORITY_DEFAULT, None, None, None)
  341. def cb_gfile_read(self, gfile, res, userdata=None):
  342. try:
  343. stream = gfile.read_finish(res)
  344. except Exception:
  345. return
  346. # Use async version to avoid blocking UI
  347. Pixbuf.new_from_stream_at_scale_async(stream, SIZE_THUMB, SIZE_THUMB, True,
  348. None, self.cb_pixbuf_read, gfile)
  349. def cb_pixbuf_read(self, stream, res, userdata):
  350. try:
  351. pix = Pixbuf.new_from_stream_finish(res)
  352. except Exception:
  353. return
  354. if not pix:
  355. return
  356. rounded = round_pixbuf(pix, RADIUS_THUMB)
  357. gfile = userdata
  358. self.store_thumbnails.append((gfile.get_uri(), gfile.get_basename(),
  359. rounded, '', 0))
  360. # Use async version to avoid blocking UI
  361. stream.close_async(GLib.PRIORITY_DEFAULT, None, None, None)
  362. def login_to_oauth(self, url, url_checker):
  363. webdiag = AuthDialog(self.window, url, url_checker)
  364. resp = webdiag.run()
  365. webdiag.destroy()
  366. # Turn server off
  367. self.app.stop_server()
  368. if resp == Gtk.ResponseType.OK:
  369. return True
  370. return False
  371. def get_photo_paths(self):
  372. return ((r[COL_FI_PATH], r[COL_FI_DESCR]) for r in self.store_thumbnails)
  373. def make_album_selected(self, aid):
  374. for i, row in enumerate(self.store_albums):
  375. if row[COL_AL_ID] == str(aid):
  376. self.combo_album.set_active(i)
  377. break
  378. def set_progress(self, idx, value):
  379. model = self.store_thumbnails
  380. itr = model.get_iter(idx)
  381. if itr:
  382. model.set_value(itr, COL_FI_PROGR, value)
  383. def reset_all_progress_bars(self):
  384. for row in self.store_thumbnails:
  385. if row[COL_FI_PROGR]:
  386. row[COL_FI_PROGR] = 0
  387. self.bar_upload.set_fraction(0)
  388. def get_shrink_size(self):
  389. text = self.combo_size.get_active_text()
  390. if not text or 'x' not in text:
  391. return None
  392. w, h = text.split('x')
  393. return int(w)
  394. def wait_load_albums(self):
  395. grid = self.toolgrid
  396. grid.remove(self.toolbar_album)
  397. spinner = Gtk.Spinner()
  398. grid.attach(spinner, 2, 1, 1, 1)
  399. # Make spinner look smaller
  400. spinner.set_valign(Gtk.Align.CENTER)
  401. spinner.show()
  402. spinner.start()
  403. def unwait_load_albums(self):
  404. grid = self.toolgrid
  405. spinner = grid.get_child_at(2, 1)
  406. spinner.stop()
  407. grid.remove(spinner)
  408. grid.attach(self.toolbar_album, 2, 1, 1, 1)
  409. class AddAccountDialog(Gtk.Dialog):
  410. def __init__(self, parent):
  411. super(AddAccountDialog, self).__init__(
  412. 'Add account', parent, 0,
  413. (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
  414. Gtk.STOCK_ADD, Gtk.ResponseType.OK))
  415. self.services = Gtk.ListStore(Pixbuf, str)
  416. self.services.append((get_service_pixbuf('facebook'), 'facebook'))
  417. self.services.append((get_service_pixbuf('google'), 'google'))
  418. self.combox = combox = Gtk.ComboBox.new_with_model(self.services)
  419. cpix = Gtk.CellRendererPixbuf()
  420. combox.pack_start(cpix, False)
  421. combox.add_attribute(cpix, 'pixbuf', 0)
  422. cname = Gtk.CellRendererText()
  423. combox.pack_start(cname, True)
  424. combox.add_attribute(cname, 'text', 1)
  425. maingrid = Gtk.Grid()
  426. maingrid.set_border_width(4)
  427. maingrid.set_column_spacing(4)
  428. maingrid.attach(Gtk.Label('Service'), 0, 0, 1, 1)
  429. maingrid.attach(combox, 1, 0, 1, 1)
  430. area = self.get_content_area()
  431. area.pack_start(maingrid, True, True, 0)
  432. maingrid.show_all()
  433. class AuthDialog(Gtk.Dialog):
  434. ''' Dialog to show Facebook authentication web page'''
  435. def __init__(self, parent, url, url_checker):
  436. '''
  437. url_checker: Function to check URL and stop the dialog if URL satisfies.
  438. '''
  439. super(AuthDialog, self).__init__('Please login...', parent=parent)
  440. self.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
  441. self.webview = webview = WebKit.WebView()
  442. webview.load_uri(url)
  443. self.check_url_to_return = url_checker
  444. webview.connect('resource-load-finished', self.check_uri)
  445. webview.set_vexpand(True)
  446. cta = self.get_content_area()
  447. cta.add(webview)
  448. self.set_default_size(500, 400)
  449. cta.show_all()
  450. def check_uri(self, web_view, web_frame, web_resource, user_data=None):
  451. if self.check_url_to_return(web_view.get_uri()):
  452. self.response(Gtk.ResponseType.OK)
  453. #===== Util functions =====#
  454. def uri_to_path(uri):
  455. p = urlsplit(uri)
  456. return unquote(p.path)
  457. def mid_ellipsis(strg, length=MAXCHAR):
  458. if len(strg) > length:
  459. mid = int(length/2)
  460. return strg[:mid] + '…' + strg[-(length-mid-1):]
  461. else:
  462. return strg
  463. def get_service_pixbuf(service):
  464. iconfolder = os.path.join(env.get_data_dir(), 'icons')
  465. filepath = os.path.join(iconfolder, service + '.png')
  466. if os.path.exists(filepath):
  467. return Pixbuf.new_from_file_at_size(filepath, 16, 16)
  468. return None
  469. def get_all_handlers(obj):
  470. return (getattr(obj, m) for m in dir(obj)
  471. if m.startswith('on_'))
  472. def get_files_under(folder):
  473. return glob.iglob(os.path.join(folder, '*'))
  474. def cairo_rounded_box(cairo_ctx, x, y, width, height, radius):
  475. cairo_ctx.new_sub_path()
  476. cairo_quater_arc(cairo_ctx, x+radius, y+radius, radius, 3)
  477. cairo_quater_arc(cairo_ctx, x+width-radius, y+radius, radius, 4)
  478. cairo_quater_arc(cairo_ctx, x+width-radius, y+height-radius, radius, 1)
  479. cairo_quater_arc(cairo_ctx, x+radius, y+height-radius, radius, 2)
  480. def cairo_quater_arc(cairo_ctx, xc, yc, radius, quater):
  481. assert type(quater) == int and quater > 0
  482. if radius <= 0.0:
  483. cairo_ctx.line_to(xc, yc)
  484. return
  485. cairo_ctx.save()
  486. cairo_ctx.translate(xc, yc)
  487. start, end = (quater - 1)*math.pi/2, quater*math.pi/2
  488. cairo_ctx.arc(0, 0, radius, start, end)
  489. cairo_ctx.restore()
  490. def round_pixbuf(pix, radius):
  491. width, height = pix.get_width(), pix.get_height()
  492. surface = cairo.ImageSurface(cairo.FORMAT_ARGB32,
  493. width, height)
  494. cr = cairo.Context(surface)
  495. Gdk.cairo_set_source_pixbuf(cr, pix, 0, 0)
  496. cairo_rounded_box(cr, 0, 0, width, height, radius)
  497. cr.clip()
  498. cr.paint()
  499. #Ref: http://whyareyoureadingthisurl.wordpress.com/2012/01/09/cairo-contextsurface-to-gdk-pixbuf/
  500. return Gdk.pixbuf_get_from_surface(surface, 0, 0,
  501. surface.get_width(),
  502. surface.get_height());
  503. # Ref: https://gist.github.com/bert/985903