Use dict-based format strings for numbers (bug 1165)
[gpodder.git] / src / gpodder / gtkui / frmntl / model.py
blob64162924b15cdc69ae05c1be5a4a296329355b5e
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2010 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
22 # gpodder.gtkui.frmntl.model -- Model customizations for Maemo 5 (2009-11-16)
25 import gpodder
27 _ = gpodder.gettext
28 N_ = gpodder.ngettext
30 import cgi
31 import gtk
33 from gpodder.gtkui import download
34 from gpodder.gtkui import model
35 from gpodder.gtkui.frmntl import style
37 from gpodder import util
39 class DownloadStatusModel(download.DownloadStatusModel):
40 def __init__(self):
41 download.DownloadStatusModel.__init__(self)
42 head_font = style.get_font_desc('SystemFont')
43 head_color = style.get_color('ButtonTextColor')
44 head = (head_font.to_string(), head_color.to_string())
45 head = '<span font_desc="%s" foreground="%s">%%s</span>' % head
46 sub_font = style.get_font_desc('SmallSystemFont')
47 sub_color = style.get_color('SecondaryTextColor')
48 sub = (sub_font.to_string(), sub_color.to_string())
49 sub = '<span font_desc="%s" foreground="%s">%%s - %%s</span>' % sub
50 self._markup_template = '\n'.join((head, sub))
52 def _format_message(self, episode, message, podcast):
53 return self._markup_template % (episode, message, podcast)
56 class EpisodeListModel(gtk.GenericTreeModel):
57 N_COLUMNS = 17
59 C_URL, C_TITLE, C_FILESIZE_TEXT, C_EPISODE, C_STATUS_ICON, \
60 C_PUBLISHED_TEXT, C_DESCRIPTION, C_TOOLTIP, \
61 C_VIEW_SHOW_UNDELETED, C_VIEW_SHOW_DOWNLOADED, \
62 C_VIEW_SHOW_UNPLAYED, C_FILESIZE, C_PUBLISHED, \
63 C_TIME, C_TIME1_VISIBLE, C_TIME2_VISIBLE, \
64 C_LOCKED = range(N_COLUMNS)
66 DATA_TYPES = (str, str, str, object, str , str, str, \
67 str, bool, bool, bool, int, int, str, bool, bool, \
68 bool)
70 SEARCH_COLUMNS = (C_TITLE,)
72 VIEW_ALL, VIEW_UNDELETED, VIEW_DOWNLOADED, VIEW_UNPLAYED = range(4)
74 # ---------------------
76 def on_get_flags(self):
77 return gtk.TREE_MODEL_LIST_ONLY
79 def on_get_n_columns(self):
80 return self.N_COLUMNS
82 def on_get_column_type(self, index):
83 return self.DATA_TYPES[index]
85 def on_get_iter(self, path):
86 return path[0]
88 def on_get_path(self, rowref):
89 return (rowref,)
91 def on_get_value(self, rowref, column):
92 if rowref >= len(self._episodes):
93 return None
95 episode = self._episodes[rowref]
96 downloading = self._downloading
98 if column == self.C_URL:
99 return episode.url
100 elif column == self.C_TITLE:
101 return episode.title
102 elif column == self.C_FILESIZE_TEXT:
103 return self._format_filesize(episode)
104 elif column == self.C_EPISODE:
105 return episode
106 elif column == self.C_STATUS_ICON:
107 if downloading(episode):
108 return self.ICON_DOWNLOADING
109 elif episode.state == gpodder.STATE_DELETED:
110 return self.ICON_DELETED
111 elif episode.state == gpodder.STATE_NORMAL and \
112 not episode.is_played and \
113 not downloading(episode):
114 return self.ICON_NEW
115 elif episode.state == gpodder.STATE_DOWNLOADED:
116 filename = episode.local_filename(create=False, \
117 check_only=True)
119 file_type = episode.file_type()
120 if file_type == 'audio':
121 status_icon = self.ICON_AUDIO_FILE
122 elif file_type == 'video':
123 status_icon = self.ICON_VIDEO_FILE
124 elif file_type == 'image':
125 status_icon = self.ICON_IMAGE_FILE
126 else:
127 status_icon = self.ICON_GENERIC_FILE
129 if gpodder.ui.maemo:
130 return status_icon
132 icon_theme = gtk.icon_theme_get_default()
133 if filename is not None and have_gio:
134 file = gio.File(filename)
135 if file.query_exists():
136 file_info = file.query_info('*')
137 icon = file_info.get_icon()
138 for icon_name in icon.get_names():
139 if icon_theme.has_icon(icon_name):
140 return icon_name
142 return status_icon
144 return None
145 elif column == self.C_PUBLISHED_TEXT:
146 return episode.cute_pubdate()
147 elif column == self.C_DESCRIPTION:
148 return self._format_description(episode)
149 elif column == self.C_TOOLTIP:
150 return ''
151 elif column == self.C_VIEW_SHOW_UNDELETED:
152 return episode.state != gpodder.STATE_DELETED or downloading(episode)
153 elif column == self.C_VIEW_SHOW_DOWNLOADED:
154 return episode.state == gpodder.STATE_DOWNLOADED or \
155 (episode.state == gpodder.STATE_NORMAL and \
156 not episode.is_played) or \
157 downloading(episode)
158 elif column == self.C_VIEW_SHOW_UNPLAYED:
159 return (not episode.is_played and (episode.state in \
160 (gpodder.STATE_DOWNLOADED, gpodder.STATE_NORMAL))) or \
161 downloading(episode)
162 elif column == self.C_FILESIZE:
163 return episode.length
164 elif column == self.C_PUBLISHED:
165 return episode.pubDate
166 elif column == self.C_TIME:
167 return episode.get_play_info_string()
168 elif column == self.C_TIME1_VISIBLE:
169 return (episode.total_time and not episode.current_position)
170 elif column == self.C_TIME2_VISIBLE:
171 return (episode.total_time and episode.current_position)
172 elif column == self.C_LOCKED:
173 return episode.is_locked and \
174 episode.state == gpodder.STATE_DOWNLOADED and \
175 episode.file_exists()
177 raise Exception('could not find column index: ' + str(column))
179 def on_iter_next(self, rowref):
180 if len(self._episodes) > rowref + 1:
181 return rowref + 1
183 return None
185 def on_iter_children(self, parent):
186 if parent is None:
187 if self._episodes:
188 return 0
190 return None
192 def on_iter_has_child(self, rowref):
193 return False
195 def on_iter_n_children(self, rowref):
196 if rowref is None:
197 return len(self._episodes)
199 return 0
201 def on_iter_nth_child(self, parent, n):
202 if parent is None:
203 return n
205 return None
207 def on_iter_parent(self, child):
208 return None
210 # ---------------------
213 def __init__(self, on_filter_changed=lambda has_episodes: None):
214 gtk.GenericTreeModel.__init__(self)
216 # Callback for when the filter / list changes, gets one parameter
217 # (has_episodes) that is True if the list has any episodes
218 self._on_filter_changed = on_filter_changed
220 self._downloading = lambda x: False
221 self._include_description = False
222 self._generate_thumbnails = False
224 self._episodes = []
226 # Filter to allow hiding some episodes
227 self._filter = self.filter_new()
228 self._view_mode = self.VIEW_ALL
229 self._search_term = None
230 self._filter.set_visible_func(self._filter_visible_func)
232 # Are we currently showing the "all episodes" view?
233 self._all_episodes_view = False
235 # "ICON" is used to mark icon names in source files
236 ICON = lambda x: x
238 self._icon_cache = {}
239 self.ICON_AUDIO_FILE = ICON('general_audio_file')
240 self.ICON_VIDEO_FILE = ICON('general_video_file')
241 self.ICON_IMAGE_FILE = ICON('general_image')
242 self.ICON_GENERIC_FILE = ICON('filemanager_unknown_file')
243 self.ICON_DOWNLOADING = ICON('email_inbox')
244 self.ICON_DELETED = ICON('camera_delete')
245 self.ICON_UNPLAYED = ICON('emblem-new')
246 self.ICON_LOCKED = ICON('emblem-readonly')
247 self.ICON_MISSING = ICON('emblem-unreadable')
248 self.ICON_NEW = gtk.STOCK_ABOUT
250 normal_font = style.get_font_desc('SystemFont')
251 normal_color = style.get_color('DefaultTextColor')
252 normal = (normal_font.to_string(), normal_color.to_string())
253 self._normal_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % normal
255 active_font = style.get_font_desc('SystemFont')
256 active_color = style.get_color('ActiveTextColor')
257 active = (active_font.to_string(), active_color.to_string())
258 self._active_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % active
260 sub_font = style.get_font_desc('SmallSystemFont')
261 sub_color = style.get_color('SecondaryTextColor')
262 sub = (sub_font.to_string(), sub_color.to_string())
263 sub = '\n<span font_desc="%s" foreground="%s">%%s</span>' % sub
265 self._unplayed_markup = self._normal_markup + sub
266 self._active_markup += sub
270 def _format_filesize(self, episode):
271 if episode.length > 0:
272 return util.format_filesize(episode.length, 1)
273 else:
274 return None
277 def _filter_visible_func(self, model, iter):
278 # If searching is active, set visibility based on search text
279 if self._search_term is not None:
280 key = self._search_term.lower()
281 return any((key in (model.get_value(iter, column) or '').lower()) for column in self.SEARCH_COLUMNS)
283 if self._view_mode == self.VIEW_ALL:
284 return True
285 elif self._view_mode == self.VIEW_UNDELETED:
286 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
287 elif self._view_mode == self.VIEW_DOWNLOADED:
288 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
289 elif self._view_mode == self.VIEW_UNPLAYED:
290 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
292 return True
294 def has_episodes(self):
295 """Returns True if episodes are visible (filtered)
297 If episodes are visible with the current filter
298 applied, return True (otherwise return False).
301 # XXX: This must be kept in sync with the behaviour of _filter_visible_func
302 if self._search_term is not None:
303 key = self._search_term.lower()
304 is_visible = lambda episode: key in (episode.title or '').lower()
305 elif self._view_mode == self.VIEW_ALL:
306 return bool(self._episodes)
307 elif self._view_mode == self.VIEW_UNDELETED:
308 is_visible = lambda episode: episode.state != gpodder.STATE_DELETED or \
309 self._downloading(episode)
310 elif self._view_mode == self.VIEW_DOWNLOADED:
311 is_visible = lambda episode: episode.state == gpodder.STATE_DOWNLOADED or \
312 (episode.state == gpodder.STATE_NORMAL and \
313 not episode.is_played) or \
314 self._downloading(episode)
315 elif self._view_mode == self.VIEW_UNPLAYED:
316 is_visible = lambda episode: (not episode.is_played and (episode.state in \
317 (gpodder.STATE_DOWNLOADED, gpodder.STATE_NORMAL))) or \
318 self._downloading(episode)
319 else:
320 log('Should never reach this in has_episodes()!', sender=self)
321 return True
323 return any(is_visible(episode) for episode in self._episodes)
325 def get_filtered_model(self):
326 """Returns a filtered version of this episode model
328 The filtered version should be displayed in the UI,
329 as this model can have some filters set that should
330 be reflected in the UI.
332 return self._filter
334 def set_view_mode(self, new_mode):
335 """Sets a new view mode for this model
337 After setting the view mode, the filtered model
338 might be updated to reflect the new mode."""
339 if self._view_mode != new_mode:
340 self._view_mode = new_mode
341 self._filter.refilter()
342 self._on_filter_changed(self.has_episodes())
344 def get_view_mode(self):
345 """Returns the currently-set view mode"""
346 return self._view_mode
348 def set_search_term(self, new_term):
349 if self._search_term != new_term:
350 self._search_term = new_term
351 self._filter.refilter()
352 self._on_filter_changed(self.has_episodes())
354 def get_search_term(self):
355 return self._search_term
358 def _format_description(self, episode):
359 if self._downloading(episode):
360 sub = _('in downloads list')
361 if self._all_episodes_view:
362 sub = '; '.join((sub, _('from %s') % cgi.escape(episode.channel.title,)))
363 return self._unplayed_markup % (cgi.escape(episode.title), sub)
364 elif episode.is_played:
365 if self._all_episodes_view:
366 sub = _('from %s') % cgi.escape(episode.channel.title,)
367 return self._unplayed_markup % (cgi.escape(episode.title), sub)
368 else:
369 return self._normal_markup % (cgi.escape(episode.title),)
370 else:
371 if episode.was_downloaded(and_exists=True):
372 sub = _('unplayed download')
373 else:
374 sub = _('new episode')
376 if self._all_episodes_view:
377 sub = '; '.join((sub, _('from %s') % cgi.escape(episode.channel.title,)))
379 return self._active_markup % (cgi.escape(episode.title), sub)
381 def clear(self):
382 count = len(self._episodes)
383 for i in reversed(range(count)):
384 self.emit('row-deleted', (i,))
385 self._episodes.pop()
387 def replace_from_channel(self, channel, downloading=None, \
388 include_description=False, generate_thumbnails=False):
390 Add episode from the given channel to this model.
391 Downloading should be a callback.
392 include_description should be a boolean value (True if description
393 is to be added to the episode row, or False if not)
396 self._downloading = downloading
397 self._include_description = include_description
398 self._generate_thumbnails = generate_thumbnails
400 self._all_episodes_view = getattr(channel, 'ALL_EPISODES_PROXY', False)
402 old_length = len(self._episodes)
403 self._episodes = channel.get_all_episodes()
404 new_length = len(self._episodes)
406 for i in range(min(old_length, new_length)):
407 self.emit('row-changed', (i,), self.create_tree_iter(i))
409 if old_length > new_length:
410 for i in reversed(range(new_length, old_length)):
411 self.emit('row-deleted', (i,))
412 elif old_length < new_length:
413 for i in range(old_length, new_length):
414 self.emit('row-inserted', (i,), self.create_tree_iter(i))
416 self._on_filter_changed(self.has_episodes())
419 def update_all(self, downloading=None, include_description=False, \
420 generate_thumbnails=False):
422 self._downloading = downloading
423 self._include_description = include_description
424 self._generate_thumbnails = generate_thumbnails
426 for i in range(len(self._episodes)):
427 self.emit('row-changed', (i,), self.create_tree_iter(i))
429 def update_by_urls(self, urls, downloading=None, include_description=False, \
430 generate_thumbnails=False):
432 self._downloading = downloading
433 self._include_description = include_description
434 self._generate_thumbnails = generate_thumbnails
436 for index, episode in enumerate(self._episodes):
437 if episode.url in urls:
438 self.emit('row-changed', (index,), self.create_tree_iter(index))
440 def update_by_filter_iter(self, iter, downloading=None, \
441 include_description=False, generate_thumbnails=False):
442 # Convenience function for use by "outside" methods that use iters
443 # from the filtered episode list model (i.e. all UI things normally)
444 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter), \
445 downloading, include_description, generate_thumbnails)
447 def update_by_iter(self, iter, downloading=None, include_description=False, \
448 generate_thumbnails=False, reload_from_db=True):
450 self._downloading = downloading
451 self._include_description = include_description
452 self._generate_thumbnails = generate_thumbnails
454 index = self.get_user_data(iter)
455 episode = self._episodes[index]
456 if reload_from_db:
457 episode.reload_from_db()
459 self.emit('row-changed', (index,), self.create_tree_iter(index))
461 def _get_icon_from_image(self,image_path, icon_size):
463 Load an local image file and transform it into an icon.
465 Return a pixbuf scaled to the desired size and may return None
466 if the icon creation is impossible (file not found etc).
468 if not os.path.exists(image_path):
469 return None
470 # load image from disc (code adapted from CoverDownloader
471 # except that no download is needed here)
472 loader = gtk.gdk.PixbufLoader()
473 pixbuf = None
474 try:
475 loader.write(open(image_path, 'rb').read())
476 loader.close()
477 pixbuf = loader.get_pixbuf()
478 except:
479 log('Data error while loading image %s', image_path, sender=self)
480 return None
481 # Now scale the image with ratio (copied from _resize_pixbuf_keep_ratio)
482 # Resize if too wide
483 if pixbuf.get_width() > icon_size:
484 f = float(icon_size)/pixbuf.get_width()
485 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
486 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
487 # Resize if too high
488 if pixbuf.get_height() > icon_size:
489 f = float(icon_size)/pixbuf.get_height()
490 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
491 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
492 return pixbuf
495 def _get_tree_icon(self, icon_name, add_bullet=False, \
496 add_padlock=False, add_missing=False, icon_size=32, \
497 build_icon_from_file = False):
499 Loads an icon from the current icon theme at the specified
500 size, suitable for display in a gtk.TreeView. Additional
501 emblems can be added on top of the icon.
503 Caching is used to speed up the icon lookup.
505 The `build_icon_from_file` argument indicates (when True) that
506 the icon has to be created on the fly from a given image
507 file. The `icon_name` argument is then interpreted as the path
508 to this file. Those specific icons will *not be cached*.
511 # Add all variables that modify the appearance of the icon, so
512 # our cache does not return the same icons for different requests
513 cache_id = (icon_name, add_bullet, add_padlock, add_missing, icon_size)
515 if cache_id in self._icon_cache:
516 return self._icon_cache[cache_id]
518 icon_theme = gtk.icon_theme_get_default()
520 try:
521 if build_icon_from_file:
522 icon = self._get_icon_from_image(icon_name,icon_size)
523 else:
524 icon = icon_theme.load_icon(icon_name, icon_size, 0)
525 except:
526 try:
527 log('Missing icon in theme: %s', icon_name, sender=self)
528 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, \
529 icon_size, 0)
530 except:
531 log('Please install the GNOME icon theme.', sender=self)
532 icon = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, \
533 True, 8, icon_size, icon_size)
535 if icon and (add_bullet or add_padlock or add_missing):
536 # We'll modify the icon, so use .copy()
537 if add_missing:
538 try:
539 icon = icon.copy()
540 # Desaturate the icon so it looks even more "missing"
541 icon.saturate_and_pixelate(icon, 0.0, False)
542 emblem = icon_theme.load_icon(self.ICON_MISSING, icon_size/2, 0)
543 (width, height) = (emblem.get_width(), emblem.get_height())
544 xpos = icon.get_width() - width
545 ypos = icon.get_height() - height
546 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
547 except:
548 pass
549 elif add_bullet:
550 try:
551 icon = icon.copy()
552 emblem = icon_theme.load_icon(self.ICON_UNPLAYED, icon_size/2, 0)
553 (width, height) = (emblem.get_width(), emblem.get_height())
554 xpos = icon.get_width() - width
555 ypos = icon.get_height() - height
556 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
557 except:
558 pass
559 if add_padlock:
560 try:
561 icon = icon.copy()
562 emblem = icon_theme.load_icon(self.ICON_LOCKED, icon_size/2, 0)
563 (width, height) = (emblem.get_width(), emblem.get_height())
564 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
565 except:
566 pass
568 self._icon_cache[cache_id] = icon
569 return icon
573 class PodcastListModel(model.PodcastListModel):
574 def __init__(self, *args):
575 model.PodcastListModel.__init__(self, *args)
577 normal_font = style.get_font_desc('SystemFont')
578 normal_color = style.get_color('DefaultTextColor')
579 normal = (normal_font.to_string(), normal_color.to_string())
580 self._normal_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % normal
582 active_font = style.get_font_desc('SystemFont')
583 active_color = style.get_color('ActiveTextColor')
584 active = (active_font.to_string(), active_color.to_string())
585 self._active_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % active
587 sub_font = style.get_font_desc('SmallSystemFont')
588 sub_color = style.get_color('SecondaryTextColor')
589 sub = (sub_font.to_string(), sub_color.to_string())
590 sub = '\n<span font_desc="%s" foreground="%s">%%s</span>' % sub
592 self._unplayed_markup = self._normal_markup + sub
593 self._active_markup += sub
595 def _format_description(self, channel, total, deleted, \
596 new, downloaded, unplayed):
597 title_markup = cgi.escape(channel.title)
598 if not channel.feed_update_enabled:
599 disabled_text = cgi.escape(_('Subscription paused'))
600 if new:
601 return self._active_markup % (title_markup, disabled_text)
602 else:
603 return self._unplayed_markup % (title_markup, disabled_text)
604 if not unplayed and not new:
605 return self._normal_markup % title_markup
607 new_text = N_('%(count)d new episode', '%(count)d new episodes', new) % {'count':new}
608 unplayed_text = N_('%(count)d unplayed download', '%(count)d unplayed downloads', unplayed) % {'count':unplayed}
609 if new and unplayed:
610 return self._active_markup % (title_markup, ', '.join((new_text, unplayed_text)))
611 elif new:
612 return self._active_markup % (title_markup, new_text)
613 elif unplayed:
614 return self._unplayed_markup % (title_markup, unplayed_text)