Implemented "mark all as read".
[straw.git] / straw / ItemView.py
blobfd732531813f38bafa76df95c623708a1275c4bb
1 """ ItemView.py
3 Module for displaying an item to the user
4 """
5 __copyright__ = "Copyright (c) 2002-2005 Free Software Foundation, Inc."
6 __license__ = """
7 Straw is free software; you can redistribute it and/or modify it under the
8 terms of the GNU General Public License as published by the Free Software
9 Foundation; either version 2 of the License, or (at your option) any later
10 version.
12 Straw is distributed in the hope that it will be useful, but WITHOUT ANY
13 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 A PARTICULAR PURPOSE. See the GNU General Public License for more details.
16 You should have received a copy of the GNU General Public License along with
17 this program; if not, write to the Free Software Foundation, Inc., 59 Temple
18 Place - Suite 330, Boston, MA 02111-1307, USA. """
20 from Constants import *
21 from MessageManager import post_status_message
22 from straw import helpers
23 from xml.sax import saxutils
24 import Config
25 import ImageCache
26 import MVP
27 import codecs
28 import error
29 import gtk
30 import gtk.glade
31 import os
32 import pygtk
33 import re
34 import straw.defs
35 import urlparse
36 pygtk.require('2.0')
38 log = error.get_logger()
40 class HTMLView(MVP.View):
42 def _initialize(self):
43 # self._widget.set_document(self._model)
44 # Make the article view focusable. gtkhtml2.View scrolls to the first
45 # link of the document which sometimes makes the first part of the
46 # document unviewable if the first link is found at the bottom part of
47 # the document. This is good for now since the template includes a
48 # link at the topmost page of the view.
49 self._widget.set_property('can-focus', True)
50 # self._widget.get_vadjustment().set_value(0)
51 self._widget.connect('button_press_event', self._button_press_event)
52 self._css = None
53 self._url = None
54 self._text_selection = None
55 self._init_css()
56 self.uifactory = helpers.UIFactory('HtmlViewActions')
57 action = self.uifactory.get_action('/htmlview_popup/text_copy')
58 action.connect('activate', self.on_copy_text_cb)
59 action = self.uifactory.get_action('/htmlview_popup/link_copy')
60 action.connect('activate', self.on_copy_url_cb)
61 action = self.uifactory.get_action('/htmlview_popup/link_open')
62 action.connect('activate', self.on_open_link_location_cb)
63 action = self.uifactory.get_action('/htmlview_popup/subscribe')
64 action.connect('activate', self.on_subscribe_cb)
65 action = self.uifactory.get_action('/htmlview_popup/zoom_in')
66 action.connect('activate', lambda *args: self.on_magnify('in'))
67 action = self.uifactory.get_action('/htmlview_popup/zoom_out')
68 action.connect('activate', lambda *args: self.on_magnify('out'))
69 action = self.uifactory.get_action('/htmlview_popup/zoom_100')
70 action.connect('activate', lambda *args: self.on_magnify('reset'))
71 self.popup = self.uifactory.get_popup('/htmlview_popup')
73 @property
74 def widget(self):
75 return self._widget
77 def _connect_signals(self, *args): pass
79 def _button_press_event(self, widget, event):
80 if event.button == 1:
81 if self._url:
82 self._presenter.display_url(self._url)
83 self._url = self._text_selection = None
84 elif event.button == 3:
85 self._text_selection = self._presenter.get_html_text_selection()
86 link_action = self.uifactory.get_action('/htmlview_popup/link_copy')
87 open_link_action = self.uifactory.get_action('/htmlview_popup/link_open')
88 subs_action = self.uifactory.get_action('/htmlview_popup/subscribe')
89 text_action = self.uifactory.get_action('/htmlview_popup/text_copy')
90 if self._url and self._text_selection:
91 link_action.set_sensitive(True)
92 open_link_action.set_sensitive(True)
93 subs_action.set_sensitive(True)
94 text_action.set_sensitive(True)
95 elif self._url:
96 text_action.set_sensitive(False)
97 link_action.set_sensitive(True)
98 open_link_action.set_sensitive(True)
99 subs_action.set_sensitive(True)
100 elif self._text_selection:
101 link_action.set_sensitive(False)
102 open_link_action.set_sensitive(False)
103 subs_action.set_sensitive(False)
104 text_action.set_sensitive(True)
105 else:
106 link_action.set_sensitive(False)
107 open_link_action.set_sensitive(False)
108 subs_action.set_sensitive(False)
109 text_action.set_sensitive(False)
110 self.uifactory.ensure_update()
111 self.popup.popup(None, None, None, event.button,
112 gtk.get_current_event_time())
113 return True
115 def on_magnify(self, action):
116 if action == "in":
117 self._widget.zoom_in()
118 elif action == "out":
119 self._widget.zoom_out()
120 else:
121 self._widget.zoom_reset()
123 # TODO: use text magnification
124 #config.text_magnification = self._widget.get_magnification()
126 def on_copy_text_cb(self, *args):
127 print args
128 self._presenter.set_clipboard_text(self._text_selection)
130 def on_copy_url_cb(self, *args):
131 self._presenter.set_clipboard_text(self._url)
133 def on_open_link_location_cb(self, *args):
134 helpers.url_show(self._url)
136 def on_subscribe_cb(self, *args):
137 import subscribe
138 subscribe.show(url=self._url)
140 def _init_css(self):
141 if self._css is None:
142 css = file(os.path.join(straw.defs.STRAW_DATA_DIR, "straw.css")).read()
143 # derive colors for blockquotes and header boxes from
144 # the GTK+ theme
145 # the GTK+ colors are in the range 0-65535
146 bgdivisor = int(65535/(9.0/10))
147 fgdivisor = 65535
148 borderdivisor = int(65535/(6.0/10))
149 gtkstyle = self._widget.get_style()
151 headerbg = "background-color: #%.2x%.2x%.2x;" % (
152 (gtkstyle.bg[gtk.STATE_NORMAL].red * 255) / bgdivisor,
153 (gtkstyle.bg[gtk.STATE_NORMAL].blue * 255) / bgdivisor,
154 (gtkstyle.bg[gtk.STATE_NORMAL].green * 255) / bgdivisor)
156 headerfg = "color: #%.2x%.2x%.2x;" % (
157 (gtkstyle.fg[gtk.STATE_NORMAL].red * 255) / fgdivisor,
158 (gtkstyle.fg[gtk.STATE_NORMAL].blue * 255) / fgdivisor,
159 (gtkstyle.fg[gtk.STATE_NORMAL].green * 255) / fgdivisor)
161 headerborder = "border-color: #%.2x%.2x%.2x;" % (
162 (gtkstyle.bg[gtk.STATE_NORMAL].red * 255) / borderdivisor,
163 (gtkstyle.bg[gtk.STATE_NORMAL].blue * 255) / borderdivisor,
164 (gtkstyle.bg[gtk.STATE_NORMAL].green * 255) / borderdivisor)
167 css = re.sub(r"/\*\*\*HEADERBG\*/", headerbg, css)
168 css = re.sub(r"/\*\*\*HEADERFG\*/", headerfg, css)
169 css = re.sub(r"/\*\*\*HEADERBORDERCOLOR\*/",
170 headerborder, css)
172 css = re.sub(r"/\*\*\*BQUOTEBG\*/", headerbg, css)
173 css = re.sub(r"/\*\*\*BQUOTEFG\*/", headerfg, css)
174 css = re.sub(r"/\*\*\*BQUOTEBORDERCOLOR\*/",
175 headerborder, css)
176 self._css = css
177 return
179 def report_error(self, title, description):
180 helpers.report_error(title, description)
182 def get_css(self):
183 return self._css
185 def get_adjustments(self):
186 return (self._widget.get_vadjustment(), self._widget.get_hadjustment())
188 def get_widget(self):
189 return self._widget
191 def connect_widget_signal(self, signal, callback):
192 self._widget.connect(signal, callback)
194 def set_on_url(self, url):
195 self._url = url
197 def set_magnification(self, size):
198 # self._widget.set_magnification(size)
199 pass
201 class HTMLPresenter(MVP.BasicPresenter):
203 Model: gtkhtml2.Document
204 View: HTMLView
206 def _initialize(self):
207 # self._model.connect('request-url', self._request_url)
208 # self._view.connect_widget_signal('on_url', self._on_url)
209 self._model.connect('status_changed', self._status_changed)
210 self._model.connect('open_uri', self._open_uri)
211 self._item = None
213 def _open_uri(self, document, url):
214 try:
215 self.display_url(url)
216 finally:
217 return True
219 def _status_changed(self, document, status):
220 post_status_message(status)
222 def _on_url(self, view, url):
223 self._view.set_on_url(url)
224 if url:
225 url = helpers.complete_url(url, self._item.feed.location)
226 else:
227 url = ""
228 post_status_message(url)
229 return
231 def _request_url(self, document, url, stream):
232 feed = self._item.feed
233 try:
234 try:
235 url = helpers.complete_url(url, self._item.feed.location)
236 if urlparse.urlparse(url)[0] == 'file':
237 # local URL, don't use the cache.
238 f = file(urlparse.urlparse(url)[2])
239 stream.write(f.read())
240 f.close()
241 else:
242 image = ImageCache.cache[url]
243 stream.write(image.get_data())
244 except Exception, ex:
245 log.error("Error reading image in %s: %s" % (url, ex))
246 finally:
247 stream.close()
248 stream = None
249 return
251 def set_clipboard_text(self, text):
252 helpers.set_clipboard_text(text)
254 def get_html_text_selection(self):
255 pass
257 def display_url(self, link):
258 link = link.strip()
259 link = helpers.complete_url(link, self._item.feed.location)
260 try:
261 helpers.url_show(link)
262 except Exception, ex:
263 print ex
264 self._view.report_error(_("Error Loading Browser"),
265 _("Please check your browser settings and try again."))
266 return
268 def get_view_adjustments(self):
269 return self._view.get_adjustments()
271 def get_view_widget(self):
272 return self._view.get_widget()
274 def display_item(self, item, encoding):
275 self._item = item
276 content = self._htmlify_item(item, encoding)
277 self._prepare_stream(content)
278 return
280 def display_empty_feed(self):
281 content = """<p class=\"emptyfeed\"/>"""# _("No data yet, need to poll first.") </p>"""
282 self._prepare_stream(content)
284 def display_empty_search(self):
285 content = """
286 <h2>Search Subscriptions</h2>
288 Begin searching by typing your text on the text box on the left side.
289 </p>
291 self._prepare_stream(content)
292 return
294 def set_view_magnification(self, size):
295 self.view.set_magnification(size)
297 def _encode_for_html(self, unicode_data, encoding='utf-8'):
298 """ From Python Cookbook, 2/ed, section 1.23
299 'html_replace' is in the utils module
301 return unicode_data.encode(encoding, 'html_replace')
303 def _prepare_stream(self, content):
304 html = self._generate_html(content)
305 html = self._encode_for_html(html)
306 # self._model.clear()
307 # self._model.open_stream("text/html")
308 # self._model.write_stream(html)
309 # self._model.close_stream()
310 self._model.render_data(html, self._item.feed.location, "text/html")
311 return
313 def _generate_html(self, body):
314 # heading
315 html = """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
316 <html>
317 <head><title>title</title>
318 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />"""
320 # stylesheet
321 if False:#Config.get_instance().reload_css:
322 html += """<link rel="stylesheet" type="text/css" href="file://"
323 """ + os.path.join(straw.defs.STRAW_DATA_DIR, "straw.css") + """/>"""
324 else:
325 html += """<style type="text/css">""" + self._view.get_css() + """</style>"""
327 # body
328 html += "</head><body>%s</body></html>" % body
329 return html
331 def _htmlify_item(self, item, encoding):
332 ret = []
334 # item header
335 ret.append('<div id="itemheader">')
336 if item.title is not None:
337 if item.link is not None:
338 ret.append('<div class="title"><a href="%s">%s</a></div>' % (item.link,item.title))
339 else:
340 ret.append(item.title)
341 ret.append('<table id="itemhead" cellspacing="0" cellpadding="0">')
342 if item.pub_date is not None:
343 timestr = helpers.format_date(
344 item.pub_date, helpers.get_date_format(), encoding)
345 ret.append(''.join(('<tr><td class="headleft" id="date">%s</td><td class="headright"></td></tr>' % str(timestr))))
347 ret.append('</table>')
348 ret.append('</div>')
350 # item body
351 if item.description is not None:
352 item.description.replace('\n', '<br/>')
353 ret.append('<div class="description">%s</div>' % item.description)
355 if len(item.publication_name):
356 ret.append('<div class="description">')
357 ret.append('<b>%s:</b> %s<br/>' % (_("Publication"),
358 item.publication_name))
359 if item.publication_volume is not None:
360 ret.append('<b>%s:</b> %s ' % (_("Volume"),
361 item.publication_volume))
362 if item.publication_number is not None:
363 ret.append('( %s )<br />' % item.publication_number)
364 if item.publication_section is not None:
365 ret.append('<b>%s:</b> %s<br />' % (_("Section"),
366 item.publication_section))
367 if item.publication_starting_page is not None:
368 ret.append('<b>%s:</b> %s' % (_("Starting Page"),
369 item.publication_starting_page))
370 ret.append('</div>')
372 # freshmeat fields
373 freshmeat_data = []
374 if item.fm_license != '' and item.fm_license is not None:
375 freshmeat_data.append('<p><b>%s:</b> %s</p>' %
376 (_("Software license"), item.fm_license))
377 if item.fm_changes != '' and item.fm_changes is not None:
378 freshmeat_data.append('<p><b>%s:</b> %s</p>' %
379 (_("Changes"), item.fm_changes))
380 if len(freshmeat_data) > 0:
381 ret.append('<div class="description">')
382 ret.extend(freshmeat_data)
383 ret.append('</div>')
384 # empty paragraph to make sure that we get space here
385 ret.append('<p></p>')
386 # Additional information
387 dcret = []
389 # RSS Enclosures
391 if item.enclosures:
392 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><table>' % _("Enclosed Media"))
393 for enc in item.enclosures: # rss 2.0 defines only one enclosure per item
394 size = int(enc.length)
395 unit = _('bytes')
396 if size > 1024:
397 unit = _('KB')
398 size /= 1024.0
399 if size > 1024:
400 unit = _('MB')
401 size /= 1024.0
402 link_text = enc['href'].split('/')[-1]
404 # find what kind of media is that. enc[type] will have something like audio/mp3 or video/mpeg (mimetypes)
405 # some symlinks are not present on the tango icon theme mimetype dir. audio and application are 2 good examples. So I am not relying on the symlinks now...
406 kind = enc['type'].split('/')[0]
407 if kind == 'audio':
408 icon_name = 'audio-x-generic'
409 elif kind == 'video':
410 icon_name = 'video-x-generic'
411 elif kind == 'image':
412 icon_name = 'image-x-generic'
413 elif kind == 'application':
414 icon_name = 'binary'
415 elif kind == 'text':
416 icon_name = 'text-x-generic'
417 else:
418 icon_name = "unknown"
420 it = gtk.icon_theme_get_default()
421 ii = it.lookup_icon(icon_name, 32, gtk.ICON_LOOKUP_NO_SVG)
422 if ii:
423 imgsrc = 'file://' + ii.get_filename()
424 else:
425 imgsrc = "file://%s/%s" % (straw.defs.STRAW_DATA_DIR, 'image-missing.svg')
426 dcret.append('<tr><td><div style="vertical-align: middle"><a class="dclink" href="%s" style="vertical-align: middle"><img style="padding: 0px 15px 5px 0px" src=%s /> %s</a> (%.2f %s - %s)</div></td></tr>' % (enc['href'], imgsrc, link_text, size, unit, enc['type']))
427 dcret.append('</table></td></tr>')
429 if item.creator is not None:
430 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><span>%s</span></td></tr>' % (_("Posted by"), item.creator))
431 if item.contributors is not None and len(item.contributors):
432 for c in item.contributors:
433 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><span>%s</span></td></tr>' \
434 % (_("Contributor:"), c.name))
435 if item.source:
436 url = helpers.get_url_location(item.source['url'])
437 text = saxutils.escape(url)
438 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><a class="dclink" href="%s"><span>%s</span></a></td></tr>' %
439 (_("Item Source"), url, text))
441 if item.guid is not None and item.guid != "" and item.guidislink:
442 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><a class="dclink" href="%s"><span>%s</span></a></td></tr>' % (_("Permalink"), item.guid, item.guid))
443 # check for not guidislink for the case where there is guid but
444 # isPermalink="false" and yet link is the same as guid (link is
445 # always assumed to be a valid link)
446 if item.link != "" and item.link is not None and (item.link != item.guid or not item.guidislink):
447 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><a class="dclink" href="%s"><span>%s</span></a></td></tr>' %
448 (_("Complete story"), item.link, item.link))
450 if item.license_urls:
451 for l in item.license_urls:
452 if l:
453 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><a class="dclink" href="%s"><span>%s</span></a></td></tr>' % (_("License"), l, l))
455 if len(dcret):
456 ret.append('<div class="dcinfo">%s<table class="dc" id="footer">' % _("Additional information"))
457 ret.append("".join(dcret))
458 ret.append('</table>')
459 ret.append('</div>')
460 return "".join(ret)
462 class ScrollView(MVP.WidgetView):
464 Widget: html_scrolled_window
466 def set_adjustments(self, vadjustment, hadjustment):
467 self._widget.set_hadjustment(hadjustment)
468 self._widget.set_vadjustment(vadjustment)
469 return
471 def add_child(self, widget):
472 self._widget.add(widget)
473 return
475 def show(self):
476 self._widget.show_all()
477 return
479 def adjust_vertical_adjustment(self):
480 va = self._widget.get_vadjustment()
481 va.set_value(va.lower)
482 return
484 def get_vadjustment(self):
485 return self._widget.get_vadjustment()
487 class ScrollPresenter(MVP.BasicPresenter):
489 View: ScrollView
491 def set_view_adjustments(self, vadjustment, hadjustment):
492 self._view.set_adjustments(vadjustment, hadjustment)
493 return
495 def update_view(self):
496 self._view.adjust_vertical_adjustment()
497 return
499 def scroll_down(self):
500 va = self._view.get_vadjustment()
501 old_value = va.get_value()
502 new_value = old_value + va.page_increment
503 limit = va.upper - va.page_size
504 if new_value > limit:
505 new_value = limit
506 va.set_value(new_value)
507 return new_value > old_value
509 def show_view(self):
510 self._view.show()
511 return
513 class ItemView:
514 def __init__(self, item_view_container):
515 self._encoding = helpers.get_locale_encoding()
516 widget_tree = gtk.glade.get_widget_tree(item_view_container)
517 # document = gtkhtml2.Document()
518 # widget = gtkhtml2.View()
519 import documentviews
520 engine_name = os.getenv('STRAW_HTML')
522 log.debug("STRAW_HTML = %s" % engine_name)
524 if engine_name:
525 engine = documentviews.create_documentviews[engine_name]
526 else:
527 engine = documentviews.default_documentview
529 document = engine(Config.straw_home())
531 log.debug("document = %s" % document)
533 widget = document.widget()
534 html_view = HTMLView(widget, document)
535 self._html_presenter = HTMLPresenter(document, html_view)
537 widget = widget_tree.get_widget('html_scrolled_window')
538 parent = widget.parent
539 parent.remove(widget)
540 widget = gtk.Frame()
541 widget.set_shadow_type(gtk.SHADOW_IN)
542 parent.add(widget)
544 # scroll_view = ScrollView(widget)
545 # self._scroll_presenter = ScrollPresenter(view=scroll_view)
547 # vadj, hadj = self._html_presenter.get_view_adjustments()
548 child = self._html_presenter.get_view_widget()
549 # self._scroll_presenter.set_view_adjustments(vadj, hadj)
550 # self._scroll_presenter.view.add_child(child)
551 # self._scroll_presenter.show_view()
553 widget.add(child)
554 # gtkmozembed visibility workaround
555 child.show()
556 widget.show()
557 # gtkhtml visibility workaround
558 widget.show_all()
560 #config = Config.get_instance()
561 #elf._html_presenter.set_view_magnification(config.text_magnification)
563 def itemlist_selection_changed(self, selection, column):
564 (model, treeiter) = selection.get_selected()
565 if not treeiter: return # .. or display a template page?
566 item = model.get_value(treeiter, column)
567 self._display_item(item)
569 def _display_item(self, item):
570 self._html_presenter.display_item(item, self._encoding)
571 # self._scroll_presenter.update_view()
572 item.set_property("is_read", True)
574 def display_empty_feed(self):
575 self._html_presenter.display_empty_feed()
577 def display_empty_search(self):
578 self._html_presenter.display_empty_search()
580 def scroll_down(self):
581 # return self._scroll_presenter.scroll_down()
582 return False
584 def get_selected_text(self):
585 return self._html_presenter.get_html_text_selection()