Wire link hover and click events from documentviews
[straw.git] / straw / ItemView.py
blobc15c5661e915eb4d8af76963fb2ef02a86363ed9
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 ImageCache
31 import MVP
32 import Config
33 import error
35 import straw.defs
36 from MessageManager import post_status_message
38 from straw import helpers
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()
122 config = Config.get_instance()
123 config.text_magnification = self._widget.get_magnification()
125 def on_copy_text_cb(self, *args):
126 print args
127 self._presenter.set_clipboard_text(self._text_selection)
128 gtkhtml2.html_selection_clear(self._widget)
129 return
131 def on_copy_url_cb(self, *args):
132 self._presenter.set_clipboard_text(self._url)
133 return
135 def on_open_link_location_cb(self, *args):
136 helpers.url_show(self._url)
138 def on_subscribe_cb(self, *args):
139 import subscribe
140 subscribe.show(url=self._url)
141 return
144 def _init_css(self):
145 if self._css is None:
146 css = file(os.path.join(straw.defs.STRAW_DATA_DIR, "straw.css")).read()
147 # derive colors for blockquotes and header boxes from
148 # the GTK+ theme
149 # the GTK+ colors are in the range 0-65535
150 bgdivisor = int(65535/(9.0/10))
151 fgdivisor = 65535
152 borderdivisor = int(65535/(6.0/10))
153 gtkstyle = self._widget.get_style()
155 headerbg = "background-color: #%.2x%.2x%.2x;" % (
156 (gtkstyle.bg[gtk.STATE_NORMAL].red * 255) / bgdivisor,
157 (gtkstyle.bg[gtk.STATE_NORMAL].blue * 255) / bgdivisor,
158 (gtkstyle.bg[gtk.STATE_NORMAL].green * 255) / bgdivisor)
160 headerfg = "color: #%.2x%.2x%.2x;" % (
161 (gtkstyle.fg[gtk.STATE_NORMAL].red * 255) / fgdivisor,
162 (gtkstyle.fg[gtk.STATE_NORMAL].blue * 255) / fgdivisor,
163 (gtkstyle.fg[gtk.STATE_NORMAL].green * 255) / fgdivisor)
165 headerborder = "border-color: #%.2x%.2x%.2x;" % (
166 (gtkstyle.bg[gtk.STATE_NORMAL].red * 255) / borderdivisor,
167 (gtkstyle.bg[gtk.STATE_NORMAL].blue * 255) / borderdivisor,
168 (gtkstyle.bg[gtk.STATE_NORMAL].green * 255) / borderdivisor)
171 css = re.sub(r"/\*\*\*HEADERBG\*/", headerbg, css)
172 css = re.sub(r"/\*\*\*HEADERFG\*/", headerfg, css)
173 css = re.sub(r"/\*\*\*HEADERBORDERCOLOR\*/",
174 headerborder, css)
176 css = re.sub(r"/\*\*\*BQUOTEBG\*/", headerbg, css)
177 css = re.sub(r"/\*\*\*BQUOTEFG\*/", headerfg, css)
178 css = re.sub(r"/\*\*\*BQUOTEBORDERCOLOR\*/",
179 headerborder, css)
180 self._css = css
181 return
183 def report_error(self, title, description):
184 helpers.report_error(title, description)
186 def get_css(self):
187 return self._css
189 def get_adjustments(self):
190 return (self._widget.get_vadjustment(), self._widget.get_hadjustment())
192 def get_widget(self):
193 return self._widget
195 def connect_widget_signal(self, signal, callback):
196 self._widget.connect(signal, callback)
198 def set_on_url(self, url):
199 self._url = url
201 def set_magnification(self, size):
202 # self._widget.set_magnification(size)
203 pass
205 class HTMLPresenter(MVP.BasicPresenter):
207 Model: gtkhtml2.Document
208 View: HTMLView
210 def _initialize(self):
211 # self._model.connect('request-url', self._request_url)
212 # self._view.connect_widget_signal('on_url', self._on_url)
213 self._model.connect('status_changed', self._status_changed)
214 self._model.connect('open_uri', self._open_uri)
215 self._item = None
217 def _open_uri(self, document, url):
218 try:
219 self.display_url(url)
220 finally:
221 return True
223 def _status_changed(self, document, status):
224 post_status_message(status)
226 def _on_url(self, view, url):
227 self._view.set_on_url(url)
228 if url:
229 url = helpers.complete_url(url, self._item.feed.location)
230 else:
231 url = ""
232 post_status_message(url)
233 return
235 def _request_url(self, document, url, stream):
236 feed = self._item.feed
237 try:
238 try:
239 url = helpers.complete_url(url, self._item.feed.location)
240 if urlparse.urlparse(url)[0] == 'file':
241 # local URL, don't use the cache.
242 f = file(urlparse.urlparse(url)[2])
243 stream.write(f.read())
244 f.close()
245 else:
246 image = ImageCache.cache[url]
247 stream.write(image.get_data())
248 except Exception, ex:
249 #error.log("Error reading image in %s: %s" % (url, ex))
250 pass
251 finally:
252 stream.close()
253 stream = None
254 return
256 def set_clipboard_text(self, text):
257 helpers.set_clipboard_text(text)
259 def get_html_text_selection(self):
260 return gtkhtml2.html_selection_get_text(self.view.widget)
262 def display_url(self, link):
263 link = link.strip()
264 link = helpers.complete_url(link, self._item.feed.location)
265 try:
266 helpers.url_show(link)
267 except Exception, ex:
268 print ex
269 self._view.report_error(_("Error Loading Browser"),
270 _("Please check your browser settings and try again."))
271 return
273 def get_view_adjustments(self):
274 return self._view.get_adjustments()
276 def get_view_widget(self):
277 return self._view.get_widget()
279 def display_item(self, item, encoding):
280 self._item = item
281 content = self._htmlify_item(item, encoding)
282 self._prepare_stream(content)
283 return
285 def display_empty_feed(self):
286 content = """<p class=\"emptyfeed\"/>"""# _("No data yet, need to poll first.") </p>"""
287 self._prepare_stream(content)
289 def display_empty_search(self):
290 content = """
291 <h2>Search Subscriptions</h2>
293 Begin searching by typing your text on the text box on the left side.
294 </p>
296 self._prepare_stream(content)
297 return
299 def set_view_magnification(self, size):
300 self.view.set_magnification(size)
302 def _encode_for_html(self, unicode_data, encoding='utf-8'):
303 """ From Python Cookbook, 2/ed, section 1.23
304 'html_replace' is in the utils module
306 return unicode_data.encode(encoding, 'html_replace')
308 def _prepare_stream(self, content):
309 html = self._generate_html(content)
310 html = self._encode_for_html(html)
311 # self._model.clear()
312 # self._model.open_stream("text/html")
313 # self._model.write_stream(html)
314 # self._model.close_stream()
315 self._model.render_data(html, self._item.feed.location, "text/html")
316 return
318 def _generate_html(self, body):
319 # heading
320 html = """<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
321 <html>
322 <head><title>title</title>
323 <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />"""
325 # stylesheet
326 if Config.get_instance().reload_css:
327 html += """<link rel="stylesheet" type="text/css" href="file://"
328 """ + os.path.join(straw.defs.STRAW_DATA_DIR, "straw.css") + """/>"""
329 else:
330 html += """<style type="text/css">""" + self._view.get_css() + """</style>"""
332 # body
333 html += "</head><body>%s</body></html>" % body
334 return html
336 def _htmlify_item(self, item, encoding):
337 feed = item.feed
338 ret = []
340 # item header
341 ret.append('<div id="itemheader">')
342 if item.title is not None:
343 if item.link is not None:
344 ret.append('<div class="title"><a href="%s">%s</a></div>' % (item.link,item.title))
345 else:
346 ret.append(item.title)
347 ret.append('<table id="itemhead" cellspacing="0" cellpadding="0">')
348 if item.pub_date is not None:
349 timestr = helpers.format_date(
350 item.pub_date, helpers.get_date_format(), encoding)
351 ret.append(''.join(('<tr><td class="headleft" id="date">%s</td><td class="headright"></td></tr>' % str(timestr))))
353 ret.append('</table>')
354 ret.append('</div>')
356 # item body
357 if item.description is not None:
358 item.description.replace('\n', '<br/>')
359 ret.append('<div class="description">%s</div>' % item.description)
361 if len(item.publication_name):
362 ret.append('<div class="description">')
363 ret.append('<b>%s:</b> %s<br/>' % (_("Publication"),
364 item.publication_name))
365 if item.publication_volume is not None:
366 ret.append('<b>%s:</b> %s ' % (_("Volume"),
367 item.publication_volume))
368 if item.publication_number is not None:
369 ret.append('( %s )<br />' % item.publication_number)
370 if item.publication_section is not None:
371 ret.append('<b>%s:</b> %s<br />' % (_("Section"),
372 item.publication_section))
373 if item.publication_starting_page is not None:
374 ret.append('<b>%s:</b> %s' % (_("Starting Page"),
375 item.publication_starting_page))
376 ret.append('</div>')
378 # freshmeat fields
379 freshmeat_data = []
380 if item.fm_license != '' and item.fm_license is not None:
381 freshmeat_data.append('<p><b>%s:</b> %s</p>' %
382 (_("Software license"), item.fm_license))
383 if item.fm_changes != '' and item.fm_changes is not None:
384 freshmeat_data.append('<p><b>%s:</b> %s</p>' %
385 (_("Changes"), item.fm_changes))
386 if len(freshmeat_data) > 0:
387 ret.append('<div class="description">')
388 ret.extend(freshmeat_data)
389 ret.append('</div>')
390 # empty paragraph to make sure that we get space here
391 ret.append('<p></p>')
392 # Additional information
393 dcret = []
395 # RSS Enclosures
397 if item.enclosures:
398 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><table>' % _("Enclosed Media"))
399 for enc in item.enclosures: # rss 2.0 defines only one enclosure per item
400 size = int(enc.length)
401 unit = _('bytes')
402 if size > 1024:
403 unit = _('KB')
404 size /= 1024.0
405 if size > 1024:
406 unit = _('MB')
407 size /= 1024.0
408 link_text = enc['href'].split('/')[-1]
410 # find what kind of media is that. enc[type] will have something like audio/mp3 or video/mpeg (mimetypes)
411 # 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...
412 kind = enc['type'].split('/')[0]
413 if kind == 'audio':
414 icon_name = 'audio-x-generic'
415 elif kind == 'video':
416 icon_name = 'video-x-generic'
417 elif kind == 'image':
418 icon_name = 'image-x-generic'
419 elif kind == 'application':
420 icon_name = 'binary'
421 elif kind == 'text':
422 icon_name = 'text-x-generic'
423 else:
424 icon_name = "unknown"
426 it = gtk.icon_theme_get_default()
427 ii = it.lookup_icon(icon_name, 32, gtk.ICON_LOOKUP_NO_SVG)
428 if ii:
429 imgsrc = 'file://' + ii.get_filename()
430 else:
431 imgsrc = "file://%s/%s" % (straw.defs.STRAW_DATA_DIR, 'image-missing.svg')
432 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']))
433 dcret.append('</table></td></tr>')
435 if item.creator is not None:
436 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))
437 if item.contributors is not None and len(item.contributors):
438 for c in item.contributors:
439 dcret.append('<tr class="tr.dc"><td class="dcname"><span>%s</span></td><td class="dcvalue"><span>%s</span></td></tr>' \
440 % (_("Contributor:"), c.name))
441 if item.source:
442 url = helpers.get_url_location(item.source['url'])
443 text = saxutils.escape(url)
444 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>' %
445 (_("Item Source"), url, text))
447 if item.guid is not None and item.guid != "" and item.guidislink:
448 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))
449 # check for not guidislink for the case where there is guid but
450 # isPermalink="false" and yet link is the same as guid (link is
451 # always assumed to be a valid link)
452 if item.link != "" and item.link is not None and (item.link != item.guid or not item.guidislink):
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>' %
454 (_("Complete story"), item.link, item.link))
456 if item.license_urls:
457 for l in item.license_urls:
458 if l:
459 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))
461 if len(dcret):
462 ret.append('<div class="dcinfo">%s<table class="dc" id="footer">' % _("Additional information"))
463 ret.append("".join(dcret))
464 ret.append('</table>')
465 ret.append('</div>')
466 return "".join(ret)
468 class ScrollView(MVP.WidgetView):
470 Widget: html_scrolled_window
472 def set_adjustments(self, vadjustment, hadjustment):
473 self._widget.set_hadjustment(hadjustment)
474 self._widget.set_vadjustment(vadjustment)
475 return
477 def add_child(self, widget):
478 self._widget.add(widget)
479 return
481 def show(self):
482 self._widget.show_all()
483 return
485 def adjust_vertical_adjustment(self):
486 va = self._widget.get_vadjustment()
487 va.set_value(va.lower)
488 return
490 def get_vadjustment(self):
491 return self._widget.get_vadjustment()
493 class ScrollPresenter(MVP.BasicPresenter):
495 View: ScrollView
497 def set_view_adjustments(self, vadjustment, hadjustment):
498 self._view.set_adjustments(vadjustment, hadjustment)
499 return
501 def update_view(self):
502 self._view.adjust_vertical_adjustment()
503 return
505 def scroll_down(self):
506 va = self._view.get_vadjustment()
507 old_value = va.get_value()
508 new_value = old_value + va.page_increment
509 limit = va.upper - va.page_size
510 if new_value > limit:
511 new_value = limit
512 va.set_value(new_value)
513 return new_value > old_value
515 def show_view(self):
516 self._view.show()
517 return
519 class ItemView:
520 def __init__(self, item_view_container):
521 self._encoding = helpers.get_locale_encoding()
522 widget_tree = gtk.glade.get_widget_tree(item_view_container)
523 # document = gtkhtml2.Document()
524 # widget = gtkhtml2.View()
525 import documentviews
526 engine_name = os.getenv('STRAW_HTML')
527 if engine_name:
528 engine = documentviews.create_documentviews[engine_name]
529 else:
530 engine = documentviews.default_documentview
531 document = engine(Config.straw_home())
532 widget = document.widget()
533 html_view = HTMLView(widget, document)
534 self._html_presenter = HTMLPresenter(document, html_view)
536 widget = widget_tree.get_widget('html_scrolled_window')
537 parent = widget.parent
538 parent.remove(widget)
539 widget = gtk.Frame()
540 widget.set_shadow_type(gtk.SHADOW_IN)
541 parent.add(widget)
543 # scroll_view = ScrollView(widget)
544 # self._scroll_presenter = ScrollPresenter(view=scroll_view)
546 # vadj, hadj = self._html_presenter.get_view_adjustments()
547 child = self._html_presenter.get_view_widget()
548 # self._scroll_presenter.set_view_adjustments(vadj, hadj)
549 # self._scroll_presenter.view.add_child(child)
550 # self._scroll_presenter.show_view()
552 widget.add(child)
553 # gtkmozembed visibility workaround
554 child.show()
555 widget.show()
556 # gtkhtml visibility workaround
557 widget.show_all()
559 config = Config.get_instance()
560 self._html_presenter.set_view_magnification(config.text_magnification)
562 def itemlist_selection_changed(self, selection, column):
563 (model, treeiter) = selection.get_selected()
564 if not treeiter: return # .. or display a template page?
565 item = model.get_value(treeiter, column)
566 self._display_item(item)
568 def _display_item(self, item):
569 self._html_presenter.display_item(item, self._encoding)
570 # self._scroll_presenter.update_view()
572 def display_empty_feed(self):
573 self._html_presenter.display_empty_feed()
575 def display_empty_search(self):
576 self._html_presenter.display_empty_search()
578 def scroll_down(self):
579 # return self._scroll_presenter.scroll_down()
580 return False
582 def get_selected_text(self):
583 return self._html_presenter.get_html_text_selection()