more Event cleanups and ItemList module refactoring
[straw.git] / src / lib / ItemView.py
blob5bcf595e8b8010b30ae453e3fae26f7b610be384
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. """
21 import os
22 import re
23 import urlparse
24 from xml.sax import saxutils
25 import codecs
26 import pygtk
27 pygtk.require('2.0')
28 import gtk
29 import gtk.glade
30 import gtkhtml2
31 import utils
32 import dialogs
33 import MessageManager
34 import FeedCategoryList
35 import ImageCache
36 import MVP
37 import Config
38 import error
39 import dialogs
41 class HTMLView(MVP.View):
42 """
43 Widget: gtkhtml2.View
44 Model: gtkhtml.Document
45 Presenter: HTMLPresenter
46 """
47 ui = """
48 <ui>
49 <popup name=\"htmlview_popup\">
50 <menuitem action=\"open_link_location\"/>
51 <menuitem action=\"copy_link_location\"/>
52 <menuitem action=\"copy_text\"/>
53 <menuitem action=\"subscribe\"/>
54 <menuitem action=\"zoom_in\"/>
55 <menuitem action=\"zoom_out\"/>
56 <menuitem action=\"zoom_100\"/>
57 </popup>
58 </ui>
59 """
61 def _initialize(self):
62 self._widget.set_document(self._model)
63 # Make the article view focusable. gtkhtml2.View scrolls to the first
64 # link of the document which sometimes makes the first part of the
65 # document unviewable if the first link is found at the bottom part of
66 # the document. This is good for now since the template includes a
67 # link at the topmost page of the view.
68 self._widget.set_property('can-focus', True)
69 self._widget.get_vadjustment().set_value(0)
70 self._widget.connect('button_press_event', self._button_press_event)
71 self._css = None
72 self._url = None
73 self._text_selection = None
74 self._popup = None
75 self._init_css()
76 self._create_popup()
79 def _connect_signals(self, widget):
80 " We don't 'auto-connect' since self._widget is not a glade file"
81 pass
83 @property
84 def widget(self):
85 return self._widget
87 def _button_press_event(self, widget, event):
88 if event.button == 1:
89 if self._url:
90 self._presenter.display_url(self._url)
91 self._url = self._text_selection = None
92 elif event.button == 3:
93 self._text_selection = self._presenter.get_html_text_selection()
94 action_group = self._uimanager.get_action_groups()[-1]
95 link_action = action_group.get_action('copy_link_location')
96 open_link_action = action_group.get_action('open_link_location')
97 text_action = action_group.get_action('copy_text')
98 subs_action = action_group.get_action('subscribe')
99 if self._url and self._text_selection:
100 link_action.set_visible(True)
101 open_link_action.set_visible(True)
102 subs_action.set_visible(True)
103 text_action.set_visible(True)
104 elif self._url:
105 text_action.set_visible(False)
106 link_action.set_visible(True)
107 open_link_action.set_visible(True)
108 subs_action.set_visible(True)
109 elif self._text_selection:
110 link_action.set_visible(False)
111 open_link_action.set_visible(False)
112 subs_action.set_visible(False)
113 text_action.set_visible(True)
114 else:
115 link_action.set_visible(False)
116 open_link_action.set_visible(False)
117 subs_action.set_visible(False)
118 text_action.set_visible(False)
119 self._uimanager.ensure_update()
120 self._popup.popup(None, None, None, event.button,
121 gtk.get_current_event_time())
122 return True
124 def _create_popup(self):
125 actions = [
126 ("copy_text", gtk.STOCK_COPY, "_Copy", None, None,
127 self._on_copy_text),
128 ("copy_link_location", None, "_Copy Link Location",
129 None, None, self._on_copy_url),
130 ("open_link_location", None, "_Open Link",
131 None, None, self._open_link_location),
132 ("subscribe", None, "_Subscribe", None, None,
133 self._subscribe),
134 ("zoom_in", gtk.STOCK_ZOOM_IN, "Zoom _In", None, None,
135 lambda *args: self._on_magnify("in")),
136 ("zoom_out", gtk.STOCK_ZOOM_OUT, "Zoom _Out", None, None,
137 lambda *args: self._on_magnify("out")),
138 ("zoom_100", gtk.STOCK_ZOOM_100, "_Normal Size", None, None,
139 lambda *args: self._on_magnify("reset"))
141 self._uimanager = gtk.UIManager()
142 actiongroup = gtk.ActionGroup("HtmlViewActions")
143 actiongroup.add_actions(actions)
144 self._uimanager.insert_action_group(actiongroup, -1)
145 self._uimanager.add_ui_from_string(self.ui)
146 self._popup = self._uimanager.get_widget("/htmlview_popup")
149 def _on_magnify(self, action):
150 if action == "in":
151 self._widget.zoom_in()
152 elif action == "out":
153 self._widget.zoom_out()
154 else:
155 self._widget.zoom_reset()
156 config = Config.get_instance()
157 config.text_magnification = self._widget.get_magnification()
159 def _on_copy_text(self, *args):
160 self._presenter.set_clipboard_text(self._text_selection)
161 gtkhtml2.html_selection_clear(self._widget)
162 return
164 def _on_copy_url(self, *args):
165 self._presenter.set_clipboard_text(self._url)
166 return
168 def _open_link_location(self, *args):
169 utils.url_show(self._url)
171 def _subscribe(self, *args):
172 import subscribe
173 subscribe.show(url=self._url)
174 return
177 def _init_css(self):
178 if self._css is None:
179 css = file(os.path.join(
180 utils.find_data_dir(), "straw.css")).read()
181 # derive colors for blockquotes and header boxes from
182 # the GTK+ theme
183 # the GTK+ colors are in the range 0-65535
184 bgdivisor = int(65535/(9.0/10))
185 fgdivisor = 65535
186 borderdivisor = int(65535/(6.0/10))
187 gtkstyle = self._widget.get_style()
189 headerbg = "background-color: #%.2x%.2x%.2x;" % (
190 (gtkstyle.bg[gtk.STATE_NORMAL].red * 255) / bgdivisor,
191 (gtkstyle.bg[gtk.STATE_NORMAL].blue * 255) / bgdivisor,
192 (gtkstyle.bg[gtk.STATE_NORMAL].green * 255) / bgdivisor)
194 headerfg = "color: #%.2x%.2x%.2x;" % (
195 (gtkstyle.fg[gtk.STATE_NORMAL].red * 255) / fgdivisor,
196 (gtkstyle.fg[gtk.STATE_NORMAL].blue * 255) / fgdivisor,
197 (gtkstyle.fg[gtk.STATE_NORMAL].green * 255) / fgdivisor)
199 headerborder = "border-color: #%.2x%.2x%.2x;" % (
200 (gtkstyle.bg[gtk.STATE_NORMAL].red * 255) / borderdivisor,
201 (gtkstyle.bg[gtk.STATE_NORMAL].blue * 255) / borderdivisor,
202 (gtkstyle.bg[gtk.STATE_NORMAL].green * 255) / borderdivisor)
205 css = re.sub(r"/\*\*\*HEADERBG\*/", headerbg, css)
206 css = re.sub(r"/\*\*\*HEADERFG\*/", headerfg, css)
207 css = re.sub(r"/\*\*\*HEADERBORDERCOLOR\*/",
208 headerborder, css)
210 css = re.sub(r"/\*\*\*BQUOTEBG\*/", headerbg, css)
211 css = re.sub(r"/\*\*\*BQUOTEFG\*/", headerfg, css)
212 css = re.sub(r"/\*\*\*BQUOTEBORDERCOLOR\*/",
213 headerborder, css)
214 self._css = css
215 return
217 def report_error(self, title, description):
218 dialogs.report_error(title, description)
220 def get_css(self):
221 return self._css
223 def get_adjustments(self):
224 return (self._widget.get_vadjustment(), self._widget.get_hadjustment())
226 def get_widget(self):
227 return self._widget
229 def connect_widget_signal(self, signal, callback):
230 self._widget.connect(signal, callback)
232 def set_on_url(self, url):
233 self._url = url
235 def set_magnification(self, size):
236 self._widget.set_magnification(size)
238 class HTMLPresenter(MVP.BasicPresenter):
240 Model: gtkhtml2.Document
241 View: HTMLView
243 def _initialize(self):
244 self._model.connect('request-url', self._request_url)
245 self._view.connect_widget_signal('on_url', self._on_url)
246 self._item = None
248 def _on_url(self, view, url):
249 self._view.set_on_url(url)
250 if url:
251 url = utils.complete_url(url, self._item.feed.location)
252 else:
253 url = ""
254 mmgr = MessageManager.get_instance()
255 mmgr.post_message(url)
256 return
258 def _request_url(self, document, url, stream):
259 feed = self._item.feed
260 try:
261 try:
262 url = utils.complete_url(url, self._item.feed.location)
263 if urlparse.urlparse(url)[0] == 'file':
264 # local URL, don't use the cache.
265 f = file(urlparse.urlparse(url)[2])
266 stream.write(f.read())
267 f.close()
268 else:
269 image = ImageCache.cache[url]
270 stream.write(image.get_data())
271 except Exception, ex:
272 error.log("Error reading image in %s: %s" % (url, ex))
273 finally:
274 stream.close()
275 stream = None
276 return
278 def set_clipboard_text(self, text):
279 utils.set_clipboard_text(text)
281 def get_html_text_selection(self):
282 return gtkhtml2.html_selection_get_text(self.view.widget)
284 def display_url(self, link):
285 link = link.strip()
286 link = utils.complete_url(link, self._item.feed.location)
287 try:
288 utils.url_show(link)
289 except Exception, ex:
290 self._view.report_error(_("Error Loading Browser"),
291 _("Please check your browser settings and try again."))
292 return
294 def get_view_adjustments(self):
295 return self._view.get_adjustments()
297 def get_view_widget(self):
298 return self._view.get_widget()
300 def display_item(self, item, encoding):
301 self._item = item
302 content = self._htmlify_item(item, encoding)
303 self._prepare_stream(content)
304 return
306 def display_empty_feed(self):
307 content = """<p class=\"emptyfeed\"/>"""# _("No data yet, need to poll first.") </p>"""
308 self._prepare_stream(content)
310 def display_empty_search(self):
311 content = """
312 <h2>Search Subscriptions</h2>
314 Begin searching by typing your text on the text box on the left side.
315 </p>
317 self._prepare_stream(content)
318 return
320 def set_view_magnification(self, size):
321 self.view.set_magnification(size)
323 def _encode_for_html(self, unicode_data, encoding='utf-8'):
324 """ From Python Cookbook, 2/ed, section 1.23
325 'html_replace' is in the utils module
327 return unicode_data.encode(encoding, 'html_replace')
329 def _prepare_stream(self, content):
330 html = self._generate_html(content)
331 html = self._encode_for_html(html)
332 self._model.clear()
333 self._model.open_stream("text/html")
334 self._model.write_stream(html)
335 self._model.close_stream()
336 return
338 def _generate_html(self, body):
339 # heading
340 html = """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
341 <html>
342 <head><title>title</title>
343 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />"""
345 # stylesheet
346 if Config.get_instance().reload_css:
347 html += """<link rel="stylesheet" type="text/css" href="file://"
348 """ + os.path.join(utils.find_data_dir(), "straw.css") + """/>"""
349 else:
350 html += """<style type="text/css">""" + self._view.get_css() + """</style>"""
352 # body
353 html += "</head><body>%s</body></html>" % body
354 return html
356 def _htmlify_item(self, item, encoding):
357 feed = item.feed
358 ret = []
360 # item header
361 ret.append('<div id="itemheader">')
362 if item.title is not None:
363 if item.link is not None:
364 ret.append('<div class="title"><a href="%s">%s</a></div>' % (item.link,item.title))
365 else:
366 ret.append(item.title)
367 ret.append('<table id="itemhead" cellspacing="0" cellpadding="0">')
368 if item.pub_date is not None:
369 timestr = utils.format_date(
370 item.pub_date, utils.get_date_format(), encoding)
371 ret.append(''.join(('<tr><td class="headleft" id="date">%s</td><td class="headright"></td></tr>' % str(timestr))))
373 ret.append('</table>')
374 ret.append('</div>')
376 # item body
377 if item.description is not None:
378 item.description.replace('\n', '<br/>')
379 ret.append('<div class="description">%s</div>' % item.description)
381 if item.publication_name is not None:
382 ret.append('<div class="description">')
383 ret.append('<b>%s:</b> %s<br/>' % (_("Publication"),
384 item.publication_name))
385 if item.publication_volume is not None:
386 ret.append('<b>%s:</b> %s ' % (_("Volume"),
387 item.publication_volume))
388 if item.publication_number is not None:
389 ret.append('( %s )<br />' % item.publication_number)
390 if item.publication_section is not None:
391 ret.append('<b>%s:</b> %s<br />' % (_("Section"),
392 item.publication_section))
393 if item.publication_starting_page is not None:
394 ret.append('<b>%s:</b> %s' % (_("Starting Page"),
395 item.publication_starting_page))
396 ret.append('</div>')
398 # freshmeat fields
399 freshmeat_data = []
400 if item.fm_license != '' and item.fm_license is not None:
401 freshmeat_data.append('<p><b>%s:</b> %s</p>' %
402 (_("Software license"), item.fm_license))
403 if item.fm_changes != '' and item.fm_changes is not None:
404 freshmeat_data.append('<p><b>%s:</b> %s</p>' %
405 (_("Changes"), item.fm_changes))
406 if len(freshmeat_data) > 0:
407 ret.append('<div class="description">')
408 ret.extend(freshmeat_data)
409 ret.append('</div>')
410 # empty paragraph to make sure that we get space here
411 ret.append('<p></p>')
412 # Additional information
413 dcret = []
415 # RSS Enclosures
417 if item.enclosures:
418 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><table>' % _("Enclosed Media"))
419 for enc in item.enclosures: # rss 2.0 defines only one enclosure per item
420 size = int(enc.length)
421 unit = _('bytes')
422 if size > 1024:
423 unit = _('KB')
424 size /= 1024.0
425 if size > 1024:
426 unit = _('MB')
427 size /= 1024.0
428 link_text = enc['href'].split('/')[-1]
430 # find what kind of media is that. enc[type] will have something like audio/mp3 or video/mpeg (mimetypes)
431 # 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...
432 kind = enc['type'].split('/')[0]
433 if kind == 'audio':
434 icon_name = 'audio-x-generic'
435 elif kind == 'video':
436 icon_name = 'video-x-generic'
437 elif kind == 'image':
438 icon_name = 'image-x-generic'
439 elif kind == 'application':
440 icon_name = 'binary'
441 elif kind == 'text':
442 icon_name = 'text-x-generic'
443 else:
444 icon_name = "unknown"
446 it = gtk.icon_theme_get_default()
447 ii = it.lookup_icon(icon_name, 32, gtk.ICON_LOOKUP_NO_SVG)
448 if ii:
449 imgsrc = 'file://' + ii.get_filename()
450 else:
451 imgsrc = "file://%s/%s" % (utils.find_image_dir(), 'image-missing.svg')
452 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']))
453 dcret.append('</table></td></tr>')
455 if item.creator is not None:
456 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))
457 if item.contributors is not None and len(item.contributors):
458 for c in item.contributors:
459 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><span>%s</span></td></tr>' \
460 % (_("Contributor:"), c.name))
461 if item.source:
462 url = utils.get_url_location(item.source['url'])
463 text = saxutils.escape(url)
464 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>' %
465 (_("Item Source"), url, text))
467 if item.guid is not None and item.guid != "" and item.guidislink:
468 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))
469 # check for not guidislink for the case where there is guid but
470 # isPermalink="false" and yet link is the same as guid (link is
471 # always assumed to be a valid link)
472 if item.link != "" and item.link is not None and (item.link != item.guid or not item.guidislink):
473 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>' %
474 (_("Complete story"), item.link, item.link))
476 if item.license_urls:
477 for l in item.license_urls:
478 if l:
479 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))
481 if len(dcret):
482 ret.append('<div class="dcinfo">%s<table class="dc" id="footer">' % _("Additional information"))
483 ret.append("".join(dcret))
484 ret.append('</table>')
485 ret.append('</div>')
486 return "".join(ret)
488 class ScrollView(MVP.WidgetView):
490 Widget: html_scrolled_window
492 def set_adjustments(self, vadjustment, hadjustment):
493 self._widget.set_hadjustment(hadjustment)
494 self._widget.set_vadjustment(vadjustment)
495 return
497 def add_child(self, widget):
498 self._widget.add(widget)
499 return
501 def show(self):
502 self._widget.show_all()
503 return
505 def adjust_vertical_adjustment(self):
506 va = self._widget.get_vadjustment()
507 va.set_value(va.lower)
508 return
510 def get_vadjustment(self):
511 return self._widget.get_vadjustment()
513 class ScrollPresenter(MVP.BasicPresenter):
515 View: ScrollView
517 def set_view_adjustments(self, vadjustment, hadjustment):
518 self._view.set_adjustments(vadjustment, hadjustment)
519 return
521 def update_view(self):
522 self._view.adjust_vertical_adjustment()
523 return
525 def scroll_down(self):
526 va = self._view.get_vadjustment()
527 old_value = va.get_value()
528 new_value = old_value + va.page_increment
529 limit = va.upper - va.page_size
530 if new_value > limit:
531 new_value = limit
532 va.set_value(new_value)
533 return new_value > old_value
535 def show_view(self):
536 self._view.show()
537 return
539 class ItemView:
540 def __init__(self, item_view_container):
541 self._encoding = utils.get_locale_encoding()
542 widget_tree = gtk.glade.get_widget_tree(item_view_container)
543 document = gtkhtml2.Document()
544 widget = gtkhtml2.View()
545 html_view = HTMLView(widget, document)
546 self._html_presenter = HTMLPresenter(document, html_view)
548 widget = widget_tree.get_widget('html_scrolled_window')
549 scroll_view = ScrollView(widget)
550 self._scroll_presenter = ScrollPresenter(view=scroll_view)
552 vadj, hadj = self._html_presenter.get_view_adjustments()
553 child = self._html_presenter.get_view_widget()
554 self._scroll_presenter.set_view_adjustments(vadj, hadj)
555 self._scroll_presenter.view.add_child(child)
556 self._scroll_presenter.show_view()
558 config = Config.get_instance()
559 self._html_presenter.set_view_magnification(config.text_magnification)
561 def itemlist_selection_changed(self, selection, column):
562 (model, treeiter) = selection.get_selected()
563 item = model.get_value(treeiter, column)
564 self._display_item(item)
566 def _display_item(self, item):
567 self._html_presenter.display_item(item, self._encoding)
568 self._scroll_presenter.update_view()
570 def display_empty_feed(self):
571 self._html_presenter.display_empty_feed()
573 def display_empty_search(self):
574 self._html_presenter.display_empty_search()
576 def scroll_down(self):
577 return self._scroll_presenter.scroll_down()
579 def get_selected_text(self):
580 return self._html_presenter.get_html_text_selection()