diff options
author | Jesús Eduardo <heckyel@hyperbola.info> | 2017-05-31 18:08:31 -0500 |
---|---|---|
committer | Jesús Eduardo <heckyel@hyperbola.info> | 2017-05-31 18:08:31 -0500 |
commit | e1180428ed3e7634fe1596103511fbb1da05f228 (patch) | |
tree | 13de9592bcde7050b089b9644839668024c518b3 /mvc/widgets/osx/layoutmanager.py | |
download | librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.tar.lz librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.tar.xz librevideoconverter-e1180428ed3e7634fe1596103511fbb1da05f228.zip |
first commit
Diffstat (limited to 'mvc/widgets/osx/layoutmanager.py')
-rw-r--r-- | mvc/widgets/osx/layoutmanager.py | 445 |
1 files changed, 445 insertions, 0 deletions
diff --git a/mvc/widgets/osx/layoutmanager.py b/mvc/widgets/osx/layoutmanager.py new file mode 100644 index 0000000..de4301b --- /dev/null +++ b/mvc/widgets/osx/layoutmanager.py @@ -0,0 +1,445 @@ +# @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. + +"""textlayout.py -- Contains the LayoutManager class. It handles laying text, +buttons, getting font metrics and other tasks that are required to size +things. +""" +import logging +import math + +from AppKit import * +from Foundation import * +from objc import YES, NO, nil + +import drawing + +INFINITE = 1000000 # size of an "infinite" dimension + +class MiroLayoutManager(NSLayoutManager): + """Overide NSLayoutManager to draw better underlines.""" + + def drawUnderlineForGlyphRange_underlineType_baselineOffset_lineFragmentRect_lineFragmentGlyphRange_containerOrigin_(self, glyph_range, typ, offset, line_rect, line_glyph_range, container_origin): + container, _ = self.textContainerForGlyphAtIndex_effectiveRange_(glyph_range.location, None) + rect = self.boundingRectForGlyphRange_inTextContainer_(glyph_range, container) + x = container_origin.x + rect.origin.x + y = (container_origin.y + rect.origin.y + rect.size.height - offset) + underline_height, offset = self.calc_underline_extents(glyph_range) + y = math.ceil(y + offset) + underline_height / 2.0 + path = NSBezierPath.bezierPath() + path.setLineWidth_(underline_height) + path.moveToPoint_(NSPoint(x, y)) + path.relativeLineToPoint_(NSPoint(rect.size.width, 0)) + path.stroke() + + def calc_underline_extents(self, line_glyph_range): + index = self.characterIndexForGlyphAtIndex_(line_glyph_range.location) + font, _ = self.textStorage().attribute_atIndex_effectiveRange_(NSFontAttributeName, index, None) + # we use a couple of magic numbers that seems to work okay. I (BDK) + # got it from some old mozilla code. + height = font.ascender() - font.descender() + height = max(1.0, round(0.05 * height)) + offset = max(1.0, round(0.1 * height)) + return height, offset + +class TextBoxPool(object): + """Handles a pool of TextBox objects. We monitor the TextBox objects and + when those objects die, we reclaim them for the pool. + + Creating TextBoxes is fairly expensive and NSLayoutManager do a lot of + caching, so it's useful to keep them around rather than destroying them. + """ + + def __init__(self): + self.used_text_boxes = [] + self.available_text_boxes = [] + + def get(self): + """Get a NSLayoutManager, either from the pool or by creating a new + one. + """ + try: + rv = self.available_text_boxes.pop() + except IndexError: + rv = TextBox() + self.used_text_boxes.append(rv) + return rv + + def reclaim_textboxes(self): + """Move used TextBoxes back to the available pool. This should be + called after the code using text boxes is done using all of them. + """ + self.available_text_boxes.extend(self.used_text_boxes) + self.used_text_boxes[:] = [] + +text_box_pool = TextBoxPool() + +class Font(object): + line_height_sizer = NSLayoutManager.alloc().init() + + def __init__(self, nsfont): + self.nsfont = nsfont + + def ascent(self): + return self.nsfont.ascender() + + def descent(self): + return -self.nsfont.descender() + + def line_height(self): + return Font.line_height_sizer.defaultLineHeightForFont_(self.nsfont) + +class FontPool(object): + def __init__(self): + self._cached_fonts = {} + + def get(self, scale_factor, bold, italic, family): + cache_key = (scale_factor, bold, italic, family) + try: + return self._cached_fonts[cache_key] + except KeyError: + font = self._create(scale_factor, bold, italic, family) + self._cached_fonts[cache_key] = font + return font + + def _create(self, scale_factor, bold, italic, family): + size = round(scale_factor * NSFont.systemFontSize()) + nsfont = None + if family is not None: + if bold: + nsfont = NSFont.fontWithName_size_(family + " Bold", size) + else: + nsfont = NSFont.fontWithName_size_(family, size) + if nsfont is None: + logging.error('FontPool: family %s scale %s bold %s ' + 'italic %s not found', + family, scale_factor, bold, italic) + # Att his point either we have requested a custom font that failed + # to load or the system font was requested. + if nsfont is None: + if bold: + nsfont = NSFont.boldSystemFontOfSize_(size) + else: + nsfont = NSFont.systemFontOfSize_(size) + return Font(nsfont) + +class LayoutManager(object): + font_pool = FontPool() + default_font = font_pool.get(1.0, False, False, None) + + def __init__(self): + self.current_font = self.default_font + self.set_text_color((0, 0, 0)) + self.set_text_shadow(None) + + def font(self, scale_factor, bold=False, italic=False, family=None): + return self.font_pool.get(scale_factor, bold, italic, family) + + def set_font(self, scale_factor, bold=False, italic=False, family=None): + self.current_font = self.font(scale_factor, bold, italic, family) + + def set_text_color(self, color): + self.text_color = color + + def set_text_shadow(self, shadow): + self.shadow = shadow + + def textbox(self, text, underline=False): + text_box = text_box_pool.get() + color = NSColor.colorWithDeviceRed_green_blue_alpha_(self.text_color[0], self.text_color[1], self.text_color[2], 1.0) + text_box.reset(text, self.current_font, color, self.shadow, underline) + return text_box + + def button(self, text, pressed=False, disabled=False, style='normal'): + if style == 'webby': + return StyledButton(text, self.current_font, pressed, disabled) + else: + return NativeButton(text, self.current_font, pressed, disabled) + + def reset(self): + text_box_pool.reclaim_textboxes() + self.current_font = self.default_font + self.text_color = (0, 0, 0) + self.shadow = None + +class TextBox(object): + def __init__(self): + self.layout_manager = MiroLayoutManager.alloc().init() + container = NSTextContainer.alloc().init() + container.setLineFragmentPadding_(0) + self.layout_manager.addTextContainer_(container) + self.layout_manager.setUsesFontLeading_(NO) + self.text_storage = NSTextStorage.alloc().init() + self.text_storage.addLayoutManager_(self.layout_manager) + self.text_container = self.layout_manager.textContainers()[0] + + def reset(self, text, font, color, shadow, underline): + """Reset the text box so it's ready to be used by a new owner.""" + self.text_storage.deleteCharactersInRange_(NSRange(0, + self.text_storage.length())) + self.text_container.setContainerSize_(NSSize(INFINITE, INFINITE)) + self.paragraph_style = NSMutableParagraphStyle.alloc().init() + self.font = font + self.color = color + self.shadow = shadow + self.width = None + self.set_text(text, underline=underline) + + def make_attr_string(self, text, color, font, underline): + attributes = NSMutableDictionary.alloc().init() + if color is not None: + nscolor = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], color[2], 1.0) + attributes.setObject_forKey_(nscolor, NSForegroundColorAttributeName) + else: + attributes.setObject_forKey_(self.color, NSForegroundColorAttributeName) + if font is not None: + attributes.setObject_forKey_(font.nsfont, NSFontAttributeName) + else: + attributes.setObject_forKey_(self.font.nsfont, NSFontAttributeName) + if underline: + attributes.setObject_forKey_(NSUnderlineStyleSingle, NSUnderlineStyleAttributeName) + attributes.setObject_forKey_(self.paragraph_style.copy(), NSParagraphStyleAttributeName) + if text is None: + text = "" + return NSAttributedString.alloc().initWithString_attributes_(text, attributes) + + def set_text(self, text, color=None, font=None, underline=False): + string = self.make_attr_string(text, color, font, underline) + self.text_storage.setAttributedString_(string) + + def append_text(self, text, color=None, font=None, underline=False): + string = self.make_attr_string(text, color, font, underline) + self.text_storage.appendAttributedString_(string) + + def set_width(self, width): + if width is not None: + self.text_container.setContainerSize_(NSSize(width, INFINITE)) + else: + self.text_container.setContainerSize_(NSSize(INFINITE, INFINITE)) + self.width = width + + def update_paragraph_style(self): + attr = NSParagraphStyleAttributeName + value = self.paragraph_style.copy() + rnge = NSMakeRange(0, self.text_storage.length()) + self.text_storage.addAttribute_value_range_(attr, value, rnge) + + def set_wrap_style(self, wrap): + if wrap == 'word': + self.paragraph_style.setLineBreakMode_(NSLineBreakByWordWrapping) + elif wrap == 'char': + self.paragraph_style.setLineBreakMode_(NSLineBreakByCharWrapping) + elif wrap == 'truncated-char': + self.paragraph_style.setLineBreakMode_(NSLineBreakByTruncatingTail) + else: + raise ValueError("Unknown wrap value: %s" % wrap) + self.update_paragraph_style() + + def set_alignment(self, align): + if align == 'left': + self.paragraph_style.setAlignment_(NSLeftTextAlignment) + elif align == 'right': + self.paragraph_style.setAlignment_(NSRightTextAlignment) + elif align == 'center': + self.paragraph_style.setAlignment_(NSCenterTextAlignment) + else: + raise ValueError("Unknown align value: %s" % align) + self.update_paragraph_style() + + def get_size(self): + # The next line is there just to force cocoa to layout the text + self.layout_manager.glyphRangeForTextContainer_(self.text_container) + rect = self.layout_manager.usedRectForTextContainer_(self.text_container) + return rect.size.width, rect.size.height + + def char_at(self, x, y): + width, height = self.get_size() + if 0 <= x < width and 0 <= y < height: + index, _ = self.layout_manager.glyphIndexForPoint_inTextContainer_fractionOfDistanceThroughGlyph_(NSPoint(x, y), self.text_container, None) + return index + else: + return None + + def draw(self, context, x, y, width, height): + if self.shadow is not None: + context.save() + context.set_shadow(self.shadow.color, self.shadow.opacity, self.shadow.offset, self.shadow.blur_radius) + self.width = width + self.text_container.setContainerSize_(NSSize(width, height)) + glyph_range = self.layout_manager.glyphRangeForTextContainer_(self.text_container) + self.layout_manager.drawGlyphsForGlyphRange_atPoint_(glyph_range, NSPoint(x, y)) + if self.shadow is not None: + context.restore() + context.path.removeAllPoints() + +class NativeButton(object): + + def __init__(self, text, font, pressed, disabled=False): + self.min_width = 0 + self.cell = NSButtonCell.alloc().init() + self.cell.setBezelStyle_(NSRoundRectBezelStyle) + self.cell.setButtonType_(NSMomentaryPushInButton) + self.cell.setFont_(font.nsfont) + self.cell.setEnabled_(not disabled) + self.cell.setTitle_(text) + if pressed: + self.cell.setState_(NSOnState) + else: + self.cell.setState_(NSOffState) + self.cell.setImagePosition_(NSImageLeft) + + def set_icon(self, icon): + image = icon.image.copy() + image.setFlipped_(NO) + self.cell.setImage_(image) + + def get_size(self): + size = self.cell.cellSize() + return size.width, size.height + + def draw(self, context, x, y, width, height): + rect = NSMakeRect(x, y, width, height) + NSGraphicsContext.currentContext().saveGraphicsState() + self.cell.drawWithFrame_inView_(rect, context.view) + NSGraphicsContext.currentContext().restoreGraphicsState() + context.path.removeAllPoints() + +class StyledButton(object): + PAD_HORIZONTAL = 11 + BIG_PAD_VERTICAL = 4 + SMALL_PAD_VERTICAL = 2 + 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.19, 0.19, 0.19) + DISABLED_COLOR = (0.86, 0.86, 0.86) + DISABLED_TEXT_COLOR = (0.43, 0.43, 0.43) + ICON_PAD = 8 + + def __init__(self, text, font, pressed, disabled=False): + self.pressed = pressed + self.disabled = disabled + attributes = NSMutableDictionary.alloc().init() + attributes.setObject_forKey_(font.nsfont, NSFontAttributeName) + if self.disabled: + color = self.DISABLED_TEXT_COLOR + else: + color = self.TEXT_COLOR + nscolor = NSColor.colorWithDeviceRed_green_blue_alpha_(color[0], color[1], color[2], 1.0) + attributes.setObject_forKey_(nscolor, NSForegroundColorAttributeName) + self.title = NSAttributedString.alloc().initWithString_attributes_(text, attributes) + self.image = None + + def set_icon(self, icon): + self.image = icon.image.copy() + self.image.setFlipped_(YES) + + def get_size(self): + width, height = self.get_text_size() + if self.image is not None: + width += self.image.size().width + self.ICON_PAD + height = max(height, self.image.size().height) + height += self.BIG_PAD_VERTICAL * 2 + else: + height += self.SMALL_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 + return width, height + + def get_text_size(self): + size = self.title.size() + return size.width, size.height + + def draw(self, context, x, y, width, height): + self._draw_button(context, x, y, width, height) + self._draw_title(context, x, y) + context.path.removeAllPoints() + + def _draw_button(self, context, x, y, width, height): + radius = height / 2 + 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 = drawing.Gradient(x, y, x, y+height) + gradient.set_start_color(start_color) + gradient.set_end_color(end_color) + context.gradient_fill(gradient) + self._draw_border(context, x, y, width, height, radius) + + 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_path_reverse(self, context, x, y, width, height, radius): + inner_width = width - radius * 2 + context.move_to(x + radius, y) + context.arc_negative(x + radius, y+radius, radius, -math.pi/2, math.pi/2) + context.rel_line_to(inner_width, 0) + context.arc_negative(x + width - radius, y+radius, radius, math.pi/2, -math.pi/2) + context.rel_line_to(-inner_width, 0) + + def _draw_border(self, context, x, y, width, height, radius): + self._draw_path(context, x, y, width, height, radius) + self._draw_path_reverse(context, x+1, y+1, width-2, height-2, radius-1) + gradient = drawing.Gradient(x, y, x, y+height) + gradient.set_start_color(self.LINE_COLOR_TOP) + gradient.set_end_color(self.LINE_COLOR_BOTTOM) + context.save() + context.clip() + context.rectangle(x, y, width, height) + context.gradient_fill(gradient) + context.restore() + + def _draw_title(self, context, x, y): + c_width, c_height = self.get_size() + t_width, t_height = self.get_text_size() + x = x + self.PAD_HORIZONTAL + y = y + (c_height - t_height) / 2 + if self.image is not None: + self.image.drawAtPoint_fromRect_operation_fraction_( + NSPoint(x, y+3), NSZeroRect, NSCompositeSourceOver, 1.0) + x += self.image.size().width + self.ICON_PAD + else: + y += 0.5 + self.title.drawAtPoint_(NSPoint(x, y)) |