Source code for timelinelib.canvas.drawing.drawers.ballondrawer

# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Rickard Lindberg, Roger Lindberg
#
# This file is part of Timeline.
#
# Timeline 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 3 of the License, or
# (at your option) any later version.
#
# Timeline 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 Timeline.  If not, see <http://www.gnu.org/licenses/>.


import math
import os

import wx

from timelinelib.config.paths import ICONS_DIR
import timelinelib.wxgui.components.font as font

BALLOON_RADIUS = 12
ARROW_OFFSET = BALLOON_RADIUS + 25
MIN_TEXT_WIDTH = 200
SLIDER_WIDTH = 20


[docs]class BallonDrawer:
[docs] def __init__(self, dc, scene, appearance, event): self._dc = dc self._scene = scene self._appearance = appearance self._event = event
[docs] def draw(self, event_rect, sticky): """Draw one ballon on a selected event that has 'description' data.""" (inner_rect_w, inner_rect_h) = (iw, _) = self.get_icon_size() font.set_balloon_text_font(self._appearance.get_balloon_font(), self._dc) max_text_width = self.max_text_width(event_rect, iw) lines = self.get_description_lines(max_text_width, iw) if lines is not None: inner_rect_w, inner_rect_h = self.calc_inner_rect(lines, inner_rect_w, inner_rect_h, max_text_width) MIN_WIDTH = 100 inner_rect_w = max(MIN_WIDTH, inner_rect_w) bounding_rect, x, y = self.draw_balloon_bg((inner_rect_w, inner_rect_h), (event_rect.X + event_rect.Width // 2, event_rect.Y), True, sticky) self.draw_icon(x, y) self.draw_description(lines, x, y) # Write data so we know where the balloon was drawn # Following two lines can be used when debugging the rectangle # self.dc.SetBrush(wx.TRANSPARENT_BRUSH) # self.dc.DrawRectangle(bounding_rect) return self._event, bounding_rect
[docs] def get_icon_size(self): (iw, ih) = (0, 0) icon = self._event.get_data("icon") if icon is not None: (iw, ih) = icon.Size return iw, ih
[docs] def max_text_width(self, event_rect, icon_width): padding = 2 * BALLOON_RADIUS if icon_width > 0: padding += BALLOON_RADIUS else: icon_width = 0 padding += icon_width visble_background = self._scene.width - SLIDER_WIDTH balloon_width = visble_background - event_rect.X - event_rect.width // 2 + ARROW_OFFSET max_text_width = balloon_width - padding return max(MIN_TEXT_WIDTH, max_text_width)
[docs] def get_description_lines(self, max_text_width, iw): description = self._event.get_data("description") if description is not None: return break_text(description, self._dc, max_text_width)
[docs] def calc_inner_rect(self, lines, w, h, max_text_width): th = len(lines) * self._dc.GetCharHeight() tw = 0 for line in lines: (lw, _) = self._dc.GetTextExtent(line) tw = max(lw, tw) if self._event.get_data("icon") is not None: w += BALLOON_RADIUS w += min(tw, max_text_width) h = max(h, th) if self._appearance.get_text_below_icon(): iw, ih = self.get_icon_size() w -= iw h = ih + th return w, h
[docs] def draw_balloon_bg(self, inner_size, tip_pos, above, sticky): """ Draw the balloon background leaving inner_size for content. tip_pos determines where the tip of the ballon should be. above determines if the balloon should be above the tip (True) or below (False). This is not currently implemented. W |----------------| ______________ _ / \ | R = Corner Radius | | | AA = Left Arrow-leg angle | W_ARROW | | H MARGIN = Text margin | |--| | | * = Starting point \____ ______/ _ / / | /_/ | H_ARROW * - |----| ARROW_OFFSET Calculation of points starts at the tip of the arrow and continues clockwise around the ballon. Return (bounding_rect, x, y) where x and y is at top of inner region. """ # Prepare path object gc = wx.GraphicsContext.Create(self._dc) path = gc.CreatePath() # Calculate path R = BALLOON_RADIUS W = 1 * R + inner_size[0] H = 1 * R + inner_size[1] H_ARROW = 14 W_ARROW = 15 AA = 20 # Starting point at the tip of the arrow (tipx, tipy) = tip_pos p0 = wx.Point(tipx, tipy) path.MoveToPoint(p0.x, p0.y) # Next point is the left base of the arrow p1 = wx.Point(p0.x + H_ARROW * math.tan(math.radians(AA)), p0.y - H_ARROW) path.AddLineToPoint(p1.x, p1.y) # Start of lower left rounded corner p2 = wx.Point(p1.x - ARROW_OFFSET + R, p1.y) path.AddLineToPoint(p2.x, p2.y) # The lower left rounded corner. p3 is the center of the arc p3 = wx.Point(p2.x, p2.y - R) path.AddArc(p3.x, p3.y, R, math.radians(90), math.radians(180), True) # The left side p4 = wx.Point(p3.x - R, p3.y - H + R) left_x = p4.x path.AddLineToPoint(p4.x, p4.y) # The upper left rounded corner. p5 is the center of the arc p5 = wx.Point(p4.x + R, p4.y) path.AddArc(p5.x, p5.y, R, math.radians(180), math.radians(-90), True) # The upper side p6 = wx.Point(p5.x + W - R, p5.y - R) top_y = p6.y path.AddLineToPoint(p6.x, p6.y) # The upper right rounded corner. p7 is the center of the arc p7 = wx.Point(p6.x, p6.y + R) path.AddArc(p7.x, p7.y, R, math.radians(-90), math.radians(0), True) # The right side p8 = wx.Point(p7.x + R, p7.y + H - R) path.AddLineToPoint(p8.x, p8.y) # The lower right rounded corner. p9 is the center of the arc p9 = wx.Point(p8.x - R, p8.y) path.AddArc(p9.x, p9.y, R, math.radians(0), math.radians(90), True) # The lower side p10 = wx.Point(p9.x - W + W_ARROW + ARROW_OFFSET, p9.y + R) path.AddLineToPoint(p10.x, p10.y) path.CloseSubpath() # Draw sharp lines on GTK which uses Cairo # See: http://www.cairographics.org/FAQ/#sharp_lines gc.Translate(0.5, 0.5) # Draw the ballon BORDER_COLOR = wx.Colour(127, 127, 127) BG_COLOR = wx.Colour(255, 255, 231) PEN = wx.Pen(BORDER_COLOR, 1, wx.PENSTYLE_SOLID) BRUSH = wx.Brush(BG_COLOR, wx.BRUSHSTYLE_SOLID) gc.SetPen(PEN) gc.SetBrush(BRUSH) gc.DrawPath(path) # Draw the pin if sticky: pin = wx.Bitmap(os.path.join(ICONS_DIR, "stickypin.png")) else: pin = wx.Bitmap(os.path.join(ICONS_DIR, "unstickypin.png")) self._dc.DrawBitmap(pin, p7.x - 5, p6.y + 5, True) # Return bx = left_x by = top_y bw = W + R + 1 bh = H + R + H_ARROW + 1 bounding_rect = wx.Rect(bx, by, bw, bh) return bounding_rect, left_x + BALLOON_RADIUS, top_y + BALLOON_RADIUS
[docs] def draw_icon(self, x, y): icon = self._event.get_data("icon") if icon is not None: self._dc.DrawBitmap(icon, x, y, False)
[docs] def draw_description(self, lines, x, y): if self._appearance.get_text_below_icon(): iw, ih = self.get_icon_size() if ih > 0: ih += BALLOON_RADIUS // 2 x -= iw y += ih if lines is not None: x = self.adjust_text_x_pos_when_icon_is_present(x) self.draw_lines(lines, x, y)
[docs] def adjust_text_x_pos_when_icon_is_present(self, x): icon = self._event.get_data("icon") (iw, _) = self.get_icon_size() if icon is not None: return x + iw + BALLOON_RADIUS else: return x
[docs] def draw_lines(self, lines, x, y): font_h = self._dc.GetCharHeight() ty = y for line in lines: self._dc.DrawText(line, x, ty) ty += font_h
[docs]def break_text(text, dc, max_width_in_px): """ Break the text into lines so that they fits within the given width.""" if type(text) == bytes: text = text.decode() sentences = text.split("\n") lines = [] for sentence in sentences: w, _ = dc.GetTextExtent(sentence) if w <= max_width_in_px: lines.append(sentence) # The sentence is too long. Break it. else: break_sentence(dc, lines, sentence, max_width_in_px) return lines
[docs]def break_sentence(dc, lines, sentence, max_width_in_px): """Break a sentence into lines.""" line = [] max_word_len_in_ch = get_max_word_length(dc, max_width_in_px) words = break_line(dc, sentence, max_word_len_in_ch) for word in words: w, _ = dc.GetTextExtent("".join(line) + word + " ") # Max line length reached. Start a new line if w > max_width_in_px: lines.append("".join(line)) line = [] line.append(word + " ") # Word edning with '-' is a broken word. Start a new line if word.endswith('-'): lines.append("".join(line)) line = [] if len(line) > 0: lines.append("".join(line))
[docs]def break_line(dc, sentence, max_word_len_in_ch): """Break a sentence into words.""" words = sentence.split(" ") new_words = [] for word in words: broken_words = break_word(dc, word, max_word_len_in_ch) for broken_word in broken_words: new_words.append(broken_word) return new_words
[docs]def break_word(dc, word, max_word_len_in_ch): """ Break words if they are too long. If a single word is too long to fit we have to break it. If not we just return the word given. """ words = [] while len(word) > max_word_len_in_ch: word1 = word[0:max_word_len_in_ch] + "-" word = word[max_word_len_in_ch:] words.append(word1) words.append(word) return words
[docs]def get_max_word_length(dc, max_width_in_px): TEMPLATE_CHAR = 'K' word = [TEMPLATE_CHAR] w, _ = dc.GetTextExtent("".join(word)) while w < max_width_in_px: word.append(TEMPLATE_CHAR) w, _ = dc.GetTextExtent("".join(word)) return len(word) - 1