# 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 miro import util
from miro.plat.frontends.widgets import use_native_buttons
from miro.frontends.widgets.gtk import pygtkhacks

class FontCache(util.Cache):
    def get(self, context, description, scale_factor, bold, italic):
        key = (context, description, scale_factor, bold, italic)
        return util.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 == 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)
