# @Base: Miro - an RSS based video player application # Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011 # Participatory Culture Foundation # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # # In addition, as a special exception, the copyright holders give # permission to link the code of portions of this program with the OpenSSL # library. # # You must obey the GNU General Public License in all respects for all of # the code used other than OpenSSL. If you modify file(s) with this # exception, you may extend this exception to your version of the file(s), # but you are not obligated to do so. If you do not wish to do so, delete # this exception statement from your version. If you delete this exception # statement from all source files in the program, then also delete it here. """drawing.py -- Contains the LayoutManager class. LayoutManager is handles laying out complex objects for the custom drawing code like text blocks and buttons. """ import math import cairo import gtk import pango from lvc import utils use_native_buttons = False # not implemented in MVC class FontCache(utils.Cache): def get(self, context, description, scale_factor, bold, italic): key = (context, description, scale_factor, bold, italic) return utils.Cache.get(self, key) def create_new_value(self, key, invalidator=None): (context, description, scale_factor, bold, italic) = key return Font(context, description, scale_factor, bold, italic) _font_cache = FontCache(512) class LayoutManager(object): def __init__(self, widget): self.pango_context = widget.get_pango_context() self.update_style(widget.style) self.update_direction(widget.get_direction()) widget.connect('style-set', self.on_style_set) widget.connect('direction-changed', self.on_direction_changed) self.widget = widget self.reset() def reset(self): self.current_font = self.font(1.0) self.text_color = (0, 0, 0) self.text_shadow = None def on_style_set(self, widget, previous_style): old_font_desc = self.style_font_desc self.update_style(widget.style) if self.style_font_desc != old_font_desc: # bug #17423 font changed, so the widget's width might have changed widget.queue_resize() def on_direction_changed(self, widget, previous_direction): self.update_direction(widget.get_direction()) def update_style(self, style): self.style_font_desc = style.font_desc self.style = style def update_direction(self, direction): if direction == gtk.TEXT_DIR_RTL: self.pango_context.set_base_dir(pango.DIRECTION_RTL) else: self.pango_context.set_base_dir(pango.DIRECTION_LTR) def font(self, scale_factor, bold=False, italic=False, family=None): return _font_cache.get(self.pango_context, self.style_font_desc, scale_factor, bold, italic) def set_font(self, scale_factor, bold=False, italic=False, family=None): self.current_font = self.font(scale_factor, bold, italic) def set_text_color(self, color): self.text_color = color def set_text_shadow(self, shadow): self.text_shadow = shadow def textbox(self, text, underline=False): textbox = TextBox(self.pango_context, self.current_font, self.text_color, self.text_shadow) textbox.set_text(text, underline=underline) return textbox def button(self, text, pressed=False, disabled=False, style='normal'): if style == 'webby': return StyledButton(text, self.pango_context, self.current_font, pressed, disabled) elif use_native_buttons: return NativeButton(text, self.pango_context, self.current_font, pressed, self.style, self.widget) else: return StyledButton(text, self.pango_context, self.current_font, pressed) def update_cairo_context(self, cairo_context): cairo_context.update_context(self.pango_context) class Font(object): def __init__(self, context, style_font_desc, scale, bold, italic): self.context = context self.description = style_font_desc.copy() self.description.set_size(int(scale * style_font_desc.get_size())) if bold: self.description.set_weight(pango.WEIGHT_BOLD) if italic: self.description.set_style(pango.STYLE_ITALIC) self.font_metrics = None def get_font_metrics(self): if self.font_metrics is None: self.font_metrics = self.context.get_metrics(self.description) return self.font_metrics def ascent(self): return pango.PIXELS(self.get_font_metrics().get_ascent()) def descent(self): return pango.PIXELS(self.get_font_metrics().get_descent()) def line_height(self): metrics = self.get_font_metrics() # the +1: some glyphs can be slightly taller than ascent+descent # (#17329) return (pango.PIXELS(metrics.get_ascent()) + pango.PIXELS(metrics.get_descent()) + 1) class TextBox(object): def __init__(self, context, font, color, shadow): self.layout = pango.Layout(context) self.layout.set_wrap(pango.WRAP_WORD_CHAR) self.font = font self.color = color self.layout.set_font_description(font.description.copy()) self.width = self.height = None self.shadow = shadow def set_text(self, text, font=None, color=None, underline=False): self.text_chunks = [] self.attributes = [] self.text_length = 0 self.underlines = [] self.append_text(text, font, color, underline) def append_text(self, text, font=None, color=None, underline=False): if text is None: text = u"" startpos = self.text_length self.text_chunks.append(text) endpos = self.text_length = self.text_length + len(text) if font is not None: attr = pango.AttrFontDesc(font.description, startpos, endpos) self.attributes.append(attr) if underline: self.underlines.append((startpos, endpos)) if color: def convert(value): return int(round(value * 65535)) attr = pango.AttrForeground(convert(color[0]), convert(color[1]), convert(color[2]), startpos, endpos) self.attributes.append(attr) self.text_set = False def set_width(self, width): if width is not None: self.layout.set_width(int(width * pango.SCALE)) else: self.layout.set_width(-1) self.width = width def set_height(self, height): # if height is not None: # # not sure why set_height isn't in the python bindings, but it # # isn't # pygtkhacks.set_pango_layout_height(self.layout, # int(height * pango.SCALE)) self.height = height def set_wrap_style(self, wrap): if wrap == 'word': self.layout.set_wrap(pango.WRAP_WORD_CHAR) elif wrap == 'char' or wrap == 'truncated-char': self.layout.set_wrap(pango.WRAP_CHAR) else: raise ValueError("Unknown wrap value: %s" % wrap) if wrap == 'truncated-char': self.layout.set_ellipsize(pango.ELLIPSIZE_END) else: self.layout.set_ellipsize(pango.ELLIPSIZE_NONE) def set_alignment(self, align): if align == 'left': self.layout.set_alignment(pango.ALIGN_LEFT) elif align == 'right': self.layout.set_alignment(pango.ALIGN_RIGHT) elif align == 'center': self.layout.set_alignment(pango.ALIGN_CENTER) else: raise ValueError("Unknown align value: %s" % align) def ensure_layout(self): if not self.text_set: text = ''.join(self.text_chunks) if len(text) > 100: text = text[:self._calc_text_cutoff()] self.layout.set_text(text) attr_list = pango.AttrList() for attr in self.attributes: attr_list.insert(attr) self.layout.set_attributes(attr_list) self.text_set = True def _calc_text_cutoff(self): """This method is a bit of a hack... GTK slows down if we pass too much text to the layout. Even text that falls below our height has a performance penalty. Try not to have too much more than is necessary. """ if None in (self.width, self.height): return -1 chars_per_line = (self.width * pango.SCALE // self.font.get_font_metrics().get_approximate_char_width()) lines_available = self.height // self.font.line_height() # overestimate these because it's better to have too many characters # than too little. return int(chars_per_line * lines_available * 1.2) def line_count(self): self.ensure_layout() return self.layout.get_line_count() def get_size(self): self.ensure_layout() return self.layout.get_pixel_size() def char_at(self, x, y): self.ensure_layout() x *= pango.SCALE y *= pango.SCALE width, height = self.layout.get_size() if 0 <= x < width and 0 <= y < height: index, leading = self.layout.xy_to_index(x, y) # xy_to_index returns the nearest character, but that # doesn't mean the user actually clicked on it. Double # check that (x, y) is actually inside that char's # bounding box char_x, char_y, char_w, char_h = self.layout.index_to_pos(index) if char_w > 0: # the glyph is LTR left = char_x right = char_x + char_w else: # the glyph is RTL left = char_x + char_w right = char_x if left <= x < right: return index return None def draw(self, context, x, y, width, height): self.set_width(width) self.set_height(height) self.ensure_layout() cairo_context = context.context cairo_context.save() underline_drawer = UnderlineDrawer(self.underlines) if self.shadow: # draw shadow first so that it's underneath the regular text # FIXME: we don't use the blur_radius setting cairo_context.set_source_rgba(self.shadow.color[0], self.shadow.color[1], self.shadow.color[2], self.shadow.opacity) self._draw_layout(context, x + self.shadow.offset[0], y + self.shadow.offset[1], width, height, underline_drawer) cairo_context.set_source_rgb(*self.color) self._draw_layout(context, x, y, width, height, underline_drawer) cairo_context.restore() cairo_context.new_path() def _draw_layout(self, context, x, y, width, height, underline_drawer): line_height = 0 alignment = self.layout.get_alignment() for i in xrange(self.layout.get_line_count()): line = self.layout.get_line_readonly(i) extents = line.get_pixel_extents()[1] next_line_height = line_height + extents[3] if next_line_height > height: break if alignment == pango.ALIGN_CENTER: line_x = max(x, x + (width - extents[2]) / 2.0) elif alignment == pango.ALIGN_RIGHT: line_x = max(x, x + width - extents[2]) else: line_x = x baseline = y + line_height + pango.ASCENT(extents) context.move_to(line_x, baseline) context.context.show_layout_line(line) underline_drawer.draw(context, line_x, baseline, line) line_height = next_line_height class UnderlineDrawer(object): """Class to draw our own underlines because cairo's don't look that great at small fonts. We make sure that the underline is always drawn at a pixel boundary and that there always is space between the text and the baseline. This class makes a couple assumptions that might not be that great. It assumes that the correct underline size is 1 pixel and that the text color doesn't change in the middle of an underline. """ def __init__(self, underlines): self.underline_iter = iter(underlines) self.finished = False self.next_underline() def next_underline(self): try: self.startpos, self.endpos = self.underline_iter.next() except StopIteration: self.finished = True else: # endpos is the char to stop underlining at self.endpos -= 1 def draw(self, context, x, baseline, line): baseline = round(baseline) + 0.5 context.set_line_width(1) while not self.finished and line.start_index <= self.startpos: startpos = max(line.start_index, self.startpos) endpos = min(self.endpos, line.start_index + line.length) x1 = x + pango.PIXELS(line.index_to_x(startpos, 0)) x2 = x + pango.PIXELS(line.index_to_x(endpos, 1)) context.move_to(x1, baseline + 1) context.line_to(x2, baseline + 1) context.stroke() if endpos < self.endpos: break else: self.next_underline() class NativeButton(object): ICON_PAD = 4 def __init__(self, text, context, font, pressed, style, widget): self.layout = pango.Layout(context) self.font = font self.pressed = pressed self.layout.set_font_description(font.description.copy()) self.layout.set_text(text) self.pad_x = style.xthickness + 11 self.pad_y = style.ythickness + 1 self.style = style self.widget = widget # The above code assumes an "inner-border" style property of # 1. PyGTK doesn't seem to support Border objects very well, # so can't get it from the widget style. self.min_width = 0 self.icon = None def set_min_width(self, width): self.min_width = width def set_icon(self, icon): self.icon = icon def get_size(self): width, height = self.layout.get_pixel_size() if self.icon: width += self.icon.width + self.ICON_PAD height = max(height, self.icon.height) width += self.pad_x * 2 height += self.pad_y * 2 return max(self.min_width, width), height def draw(self, context, x, y, width, height): text_width, text_height = self.layout.get_pixel_size() if self.icon: inner_width = text_width + self.icon.width + self.ICON_PAD # calculate the icon position x and y are still in cairo # coordinates icon_x = x + (width - inner_width) / 2.0 icon_y = y + (height - self.icon.height) / 2.0 text_x = icon_x + self.icon.width + self.ICON_PAD else: text_x = x + (width - text_width) / 2.0 text_y = y + (height - text_height) / 2.0 x, y = context.context.user_to_device(x, y) text_x, text_y = context.context.user_to_device(text_x, text_y) # Hmm, maybe we should somehow support floating point numbers # here, but I don't know how to. x, y, width, height = (int(f) for f in (x, y, width, height)) context.context.get_target().flush() self.draw_box(context.window, x, y, width, height) self.draw_text(context.window, text_x, text_y) if self.icon: self.icon.draw(context, icon_x, icon_y, self.icon.width, self.icon.height) def draw_box(self, window, x, y, width, height): if self.pressed: shadow = gtk.SHADOW_IN state = gtk.STATE_ACTIVE else: shadow = gtk.SHADOW_OUT state = gtk.STATE_NORMAL if 'QtCurveStyle' in str(self.style): # This is a horrible hack for the libqtcurve library. See # http://bugzilla.pculture.org/show_bug.cgi?id=10380 for # details widget = window.get_user_data() else: widget = self.widget self.style.paint_box(window, state, shadow, None, widget, "button", int(x), int(y), int(width), int(height)) def draw_text(self, window, x, y): if self.pressed: state = gtk.STATE_ACTIVE else: state = gtk.STATE_NORMAL self.style.paint_layout(window, state, True, None, None, None, int(x), int(y), self.layout) class StyledButton(object): PAD_HORIZONTAL = 4 PAD_VERTICAL = 3 TOP_COLOR = (1, 1, 1) BOTTOM_COLOR = (0.86, 0.86, 0.86) LINE_COLOR_TOP = (0.71, 0.71, 0.71) LINE_COLOR_BOTTOM = (0.45, 0.45, 0.45) TEXT_COLOR = (0.184, 0.184, 0.184) DISABLED_COLOR = (0.86, 0.86, 0.86) DISABLED_TEXT_COLOR = (0.5, 0.5, 0.5) ICON_PAD = 8 def __init__(self, text, context, font, pressed, disabled=False): self.layout = pango.Layout(context) self.font = font self.layout.set_font_description(font.description.copy()) self.layout.set_text(text) self.min_width = 0 self.pressed = pressed self.disabled = disabled self.icon = None def set_icon(self, icon): self.icon = icon def set_min_width(self, width): self.min_width = width def get_size(self): width, height = self.layout.get_pixel_size() if self.icon: width += self.icon.width + self.ICON_PAD height = max(height, self.icon.height) height += self.PAD_VERTICAL * 2 if height % 2 == 1: # make height even so that the radius of our circle is # whole height += 1 width += self.PAD_HORIZONTAL * 2 + height return max(self.min_width, width), height def draw_path(self, context, x, y, width, height, radius): inner_width = width - radius * 2 context.move_to(x + radius, y) context.rel_line_to(inner_width, 0) context.arc(x + width - radius, y+radius, radius, -math.pi/2, math.pi/2) context.rel_line_to(-inner_width, 0) context.arc(x + radius, y+radius, radius, math.pi/2, -math.pi/2) def draw_button(self, context, x, y, width, height, radius): context.context.save() self.draw_path(context, x, y, width, height, radius) if self.disabled: end_color = self.DISABLED_COLOR start_color = self.DISABLED_COLOR elif self.pressed: end_color = self.TOP_COLOR start_color = self.BOTTOM_COLOR else: context.set_line_width(1) start_color = self.TOP_COLOR end_color = self.BOTTOM_COLOR gradient = cairo.LinearGradient(x, y, x, y + height) gradient.add_color_stop_rgb(0, *start_color) gradient.add_color_stop_rgb(1, *end_color) context.context.set_source(gradient) context.fill() context.set_line_width(1) self.draw_path(context, x+0.5, y+0.5, width, height, radius) gradient = cairo.LinearGradient(x, y, x, y + height) gradient.add_color_stop_rgb(0, *self.LINE_COLOR_TOP) gradient.add_color_stop_rgb(1, *self.LINE_COLOR_BOTTOM) context.context.set_source(gradient) context.stroke() context.context.restore() def draw(self, context, x, y, width, height): radius = height / 2 self.draw_button(context, x, y, width, height, radius) text_width, text_height = self.layout.get_pixel_size() # draw the text in the center of the button text_x = x + (width - text_width) / 2 text_y = y + (height - text_height) / 2 if self.icon: icon_x = text_x - (self.icon.width + self.ICON_PAD) / 2 text_x += (self.icon.width + self.ICON_PAD) / 2 icon_y = y + (height - self.icon.height) / 2 self.icon.draw(context, icon_x, icon_y, self.icon.width, self.icon.height) self.draw_text(context, text_x, text_y, width, height, radius) def draw_text(self, context, x, y, width, height, radius): if self.disabled: context.set_color(self.DISABLED_TEXT_COLOR) else: context.set_color(self.TEXT_COLOR) context.move_to(x, y) context.context.show_layout(self.layout)