Fixed height mode; episode list optimization
[gpodder.git] / src / gpodder / gtkui / frmntl / model.py
blobdcc0c20665f0c2a07d6fa4d6d8c6c02fa8cfcfe5
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):
214 gtk.GenericTreeModel.__init__(self)
216 self._downloading = None
217 self._include_description = False
218 self._generate_thumbnails = False
220 self._episodes = []
222 # Filter to allow hiding some episodes
223 self._filter = self.filter_new()
224 self._sorter = gtk.TreeModelSort(self._filter)
225 self._view_mode = self.VIEW_ALL
226 self._search_term = None
227 self._filter.set_visible_func(self._filter_visible_func)
229 # Are we currently showing the "all episodes" view?
230 self._all_episodes_view = False
232 # "ICON" is used to mark icon names in source files
233 ICON = lambda x: x
235 self._icon_cache = {}
236 self.ICON_AUDIO_FILE = ICON('general_audio_file')
237 self.ICON_VIDEO_FILE = ICON('general_video_file')
238 self.ICON_IMAGE_FILE = ICON('general_image')
239 self.ICON_GENERIC_FILE = ICON('filemanager_unknown_file')
240 self.ICON_DOWNLOADING = ICON('email_inbox')
241 self.ICON_DELETED = ICON('camera_delete')
242 self.ICON_UNPLAYED = ICON('emblem-new')
243 self.ICON_LOCKED = ICON('emblem-readonly')
244 self.ICON_MISSING = ICON('emblem-unreadable')
245 self.ICON_NEW = gtk.STOCK_ABOUT
247 normal_font = style.get_font_desc('SystemFont')
248 normal_color = style.get_color('DefaultTextColor')
249 normal = (normal_font.to_string(), normal_color.to_string())
250 self._normal_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % normal
252 active_font = style.get_font_desc('SystemFont')
253 active_color = style.get_color('ActiveTextColor')
254 active = (active_font.to_string(), active_color.to_string())
255 self._active_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % active
257 sub_font = style.get_font_desc('SmallSystemFont')
258 sub_color = style.get_color('SecondaryTextColor')
259 sub = (sub_font.to_string(), sub_color.to_string())
260 sub = '\n<span font_desc="%s" foreground="%s">%%s</span>' % sub
262 self._unplayed_markup = self._normal_markup + sub
263 self._active_markup += sub
267 def _format_filesize(self, episode):
268 if episode.length > 0:
269 return util.format_filesize(episode.length, 1)
270 else:
271 return None
274 def _filter_visible_func(self, model, iter):
275 # If searching is active, set visibility based on search text
276 if self._search_term is not None:
277 key = self._search_term.lower()
278 return any((key in (model.get_value(iter, column) or '').lower()) for column in self.SEARCH_COLUMNS)
280 if self._view_mode == self.VIEW_ALL:
281 return True
282 elif self._view_mode == self.VIEW_UNDELETED:
283 return model.get_value(iter, self.C_VIEW_SHOW_UNDELETED)
284 elif self._view_mode == self.VIEW_DOWNLOADED:
285 return model.get_value(iter, self.C_VIEW_SHOW_DOWNLOADED)
286 elif self._view_mode == self.VIEW_UNPLAYED:
287 return model.get_value(iter, self.C_VIEW_SHOW_UNPLAYED)
289 return True
291 def get_filtered_model(self):
292 """Returns a filtered version of this episode model
294 The filtered version should be displayed in the UI,
295 as this model can have some filters set that should
296 be reflected in the UI.
298 return self._sorter
300 def set_view_mode(self, new_mode):
301 """Sets a new view mode for this model
303 After setting the view mode, the filtered model
304 might be updated to reflect the new mode."""
305 if self._view_mode != new_mode:
306 self._view_mode = new_mode
307 self._filter.refilter()
309 def get_view_mode(self):
310 """Returns the currently-set view mode"""
311 return self._view_mode
313 def set_search_term(self, new_term):
314 if self._search_term != new_term:
315 self._search_term = new_term
316 self._filter.refilter()
318 def get_search_term(self):
319 return self._search_term
322 def _format_description(self, episode):
323 if self._downloading(episode):
324 sub = _('in downloads list')
325 if self._all_episodes_view:
326 sub = '; '.join((sub, _('from %s') % cgi.escape(episode.channel.title,)))
327 return self._unplayed_markup % (cgi.escape(episode.title), sub)
328 elif episode.is_played:
329 if self._all_episodes_view:
330 sub = _('from %s') % cgi.escape(episode.channel.title,)
331 return self._unplayed_markup % (cgi.escape(episode.title), sub)
332 else:
333 return self._normal_markup % (cgi.escape(episode.title),)
334 else:
335 if episode.was_downloaded(and_exists=True):
336 sub = _('unplayed download')
337 else:
338 sub = _('new episode')
340 if self._all_episodes_view:
341 sub = '; '.join((sub, _('from %s') % cgi.escape(episode.channel.title,)))
343 return self._active_markup % (cgi.escape(episode.title), sub)
345 def clear(self):
346 count = len(self._episodes)
347 for i in reversed(range(count)):
348 self.emit('row-deleted', (i,))
349 self._episodes.pop()
351 def replace_from_channel(self, channel, downloading=None, \
352 include_description=False, generate_thumbnails=False):
354 Add episode from the given channel to this model.
355 Downloading should be a callback.
356 include_description should be a boolean value (True if description
357 is to be added to the episode row, or False if not)
360 self._downloading = downloading
361 self._include_description = include_description
362 self._generate_thumbnails = generate_thumbnails
364 self._all_episodes_view = getattr(channel, 'ALL_EPISODES_PROXY', False)
366 old_length = len(self._episodes)
367 self._episodes = channel.get_all_episodes()
368 new_length = len(self._episodes)
370 for i in range(min(old_length, new_length)):
371 self.emit('row-changed', (i,), self.create_tree_iter(i))
373 if old_length > new_length:
374 for i in reversed(range(new_length, old_length)):
375 self.emit('row-deleted', (i,))
376 elif old_length < new_length:
377 for i in range(old_length, new_length):
378 self.emit('row-inserted', (i,), self.create_tree_iter(i))
381 def update_all(self, downloading=None, include_description=False, \
382 generate_thumbnails=False):
384 self._downloading = downloading
385 self._include_description = include_description
386 self._generate_thumbnails = generate_thumbnails
388 for i in range(len(self._episodes)):
389 self.emit('row-changed', (i,), self.create_tree_iter(i))
391 def update_by_urls(self, urls, downloading=None, include_description=False, \
392 generate_thumbnails=False):
394 self._downloading = downloading
395 self._include_description = include_description
396 self._generate_thumbnails = generate_thumbnails
398 for index, episode in enumerate(self._episodes):
399 if episode.url in urls:
400 self.emit('row-changed', (index,), self.create_tree_iter(index))
402 def update_by_filter_iter(self, iter, downloading=None, \
403 include_description=False, generate_thumbnails=False):
404 # Convenience function for use by "outside" methods that use iters
405 # from the filtered episode list model (i.e. all UI things normally)
406 iter = self._sorter.convert_iter_to_child_iter(None, iter)
407 self.update_by_iter(self._filter.convert_iter_to_child_iter(iter), \
408 downloading, include_description, generate_thumbnails)
410 def update_by_iter(self, iter, downloading=None, include_description=False, \
411 generate_thumbnails=False, reload_from_db=True):
413 self._downloading = downloading
414 self._include_description = include_description
415 self._generate_thumbnails = generate_thumbnails
417 index = self.get_user_data(iter)
418 episode = self._episodes[index]
419 if reload_from_db:
420 episode.reload_from_db()
422 self.emit('row-changed', (index,), self.create_tree_iter(index))
424 def _get_icon_from_image(self,image_path, icon_size):
426 Load an local image file and transform it into an icon.
428 Return a pixbuf scaled to the desired size and may return None
429 if the icon creation is impossible (file not found etc).
431 if not os.path.exists(image_path):
432 return None
433 # load image from disc (code adapted from CoverDownloader
434 # except that no download is needed here)
435 loader = gtk.gdk.PixbufLoader()
436 pixbuf = None
437 try:
438 loader.write(open(image_path, 'rb').read())
439 loader.close()
440 pixbuf = loader.get_pixbuf()
441 except:
442 log('Data error while loading image %s', image_path, sender=self)
443 return None
444 # Now scale the image with ratio (copied from _resize_pixbuf_keep_ratio)
445 # Resize if too wide
446 if pixbuf.get_width() > icon_size:
447 f = float(icon_size)/pixbuf.get_width()
448 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
449 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
450 # Resize if too high
451 if pixbuf.get_height() > icon_size:
452 f = float(icon_size)/pixbuf.get_height()
453 (width, height) = (int(pixbuf.get_width()*f), int(pixbuf.get_height()*f))
454 pixbuf = pixbuf.scale_simple(width, height, gtk.gdk.INTERP_BILINEAR)
455 return pixbuf
458 def _get_tree_icon(self, icon_name, add_bullet=False, \
459 add_padlock=False, add_missing=False, icon_size=32, \
460 build_icon_from_file = False):
462 Loads an icon from the current icon theme at the specified
463 size, suitable for display in a gtk.TreeView. Additional
464 emblems can be added on top of the icon.
466 Caching is used to speed up the icon lookup.
468 The `build_icon_from_file` argument indicates (when True) that
469 the icon has to be created on the fly from a given image
470 file. The `icon_name` argument is then interpreted as the path
471 to this file. Those specific icons will *not be cached*.
474 # Add all variables that modify the appearance of the icon, so
475 # our cache does not return the same icons for different requests
476 cache_id = (icon_name, add_bullet, add_padlock, add_missing, icon_size)
478 if cache_id in self._icon_cache:
479 return self._icon_cache[cache_id]
481 icon_theme = gtk.icon_theme_get_default()
483 try:
484 if build_icon_from_file:
485 icon = self._get_icon_from_image(icon_name,icon_size)
486 else:
487 icon = icon_theme.load_icon(icon_name, icon_size, 0)
488 except:
489 try:
490 log('Missing icon in theme: %s', icon_name, sender=self)
491 icon = icon_theme.load_icon(gtk.STOCK_DIALOG_QUESTION, \
492 icon_size, 0)
493 except:
494 log('Please install the GNOME icon theme.', sender=self)
495 icon = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, \
496 True, 8, icon_size, icon_size)
498 if icon and (add_bullet or add_padlock or add_missing):
499 # We'll modify the icon, so use .copy()
500 if add_missing:
501 try:
502 icon = icon.copy()
503 # Desaturate the icon so it looks even more "missing"
504 icon.saturate_and_pixelate(icon, 0.0, False)
505 emblem = icon_theme.load_icon(self.ICON_MISSING, icon_size/2, 0)
506 (width, height) = (emblem.get_width(), emblem.get_height())
507 xpos = icon.get_width() - width
508 ypos = icon.get_height() - height
509 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
510 except:
511 pass
512 elif add_bullet:
513 try:
514 icon = icon.copy()
515 emblem = icon_theme.load_icon(self.ICON_UNPLAYED, icon_size/2, 0)
516 (width, height) = (emblem.get_width(), emblem.get_height())
517 xpos = icon.get_width() - width
518 ypos = icon.get_height() - height
519 emblem.composite(icon, xpos, ypos, width, height, xpos, ypos, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
520 except:
521 pass
522 if add_padlock:
523 try:
524 icon = icon.copy()
525 emblem = icon_theme.load_icon(self.ICON_LOCKED, icon_size/2, 0)
526 (width, height) = (emblem.get_width(), emblem.get_height())
527 emblem.composite(icon, 0, 0, width, height, 0, 0, 1, 1, gtk.gdk.INTERP_BILINEAR, 255)
528 except:
529 pass
531 self._icon_cache[cache_id] = icon
532 return icon
536 class PodcastListModel(model.PodcastListModel):
537 def __init__(self, *args):
538 model.PodcastListModel.__init__(self, *args)
540 normal_font = style.get_font_desc('SystemFont')
541 normal_color = style.get_color('DefaultTextColor')
542 normal = (normal_font.to_string(), normal_color.to_string())
543 self._normal_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % normal
545 active_font = style.get_font_desc('SystemFont')
546 active_color = style.get_color('ActiveTextColor')
547 active = (active_font.to_string(), active_color.to_string())
548 self._active_markup = '<span font_desc="%s" foreground="%s">%%s</span>' % active
550 sub_font = style.get_font_desc('SmallSystemFont')
551 sub_color = style.get_color('SecondaryTextColor')
552 sub = (sub_font.to_string(), sub_color.to_string())
553 sub = '\n<span font_desc="%s" foreground="%s">%%s</span>' % sub
555 self._unplayed_markup = self._normal_markup + sub
556 self._active_markup += sub
558 def _format_description(self, channel, total, deleted, \
559 new, downloaded, unplayed):
560 title_markup = cgi.escape(channel.title)
561 if not unplayed and not new:
562 return self._normal_markup % title_markup
564 new_text = N_('%d new episode', '%d new episodes', new) % new
565 unplayed_text = N_('%d unplayed download', '%d unplayed downloads', unplayed) % unplayed
566 if new and unplayed:
567 return self._active_markup % (title_markup, ', '.join((new_text, unplayed_text)))
568 elif new:
569 return self._active_markup % (title_markup, new_text)
570 elif unplayed:
571 return self._unplayed_markup % (title_markup, unplayed_text)