From 3dcfb9e55bff985ddcdd6aacf226e7f4d0a51c45 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Fri, 8 Apr 2022 10:45:37 +0100 Subject: [PATCH 1/3] Add a new font-editor that uses Enable to draw fonts This font editor is laregly toolkit independent - it embeds a Window with a Label component to display the font and the simple editor uses the Pyface font dialog to get new font values. Some functionality is broken out into experimental base classes for building editors with components. --- .../demo/enable/editors/font_editor.py | 46 +++++ enable/label.py | 33 +--- .../tests/trait_defs/test_kiva_font_editor.py | 184 ++++++++++++++++++ enable/trait_defs/ui/api.py | 2 + enable/trait_defs/ui/editor_with_component.py | 133 +++++++++++++ enable/trait_defs/ui/kiva_font_editor.py | 128 ++++++++++++ 6 files changed, 502 insertions(+), 24 deletions(-) create mode 100644 enable/examples/demo/enable/editors/font_editor.py create mode 100644 enable/tests/trait_defs/test_kiva_font_editor.py create mode 100644 enable/trait_defs/ui/editor_with_component.py create mode 100644 enable/trait_defs/ui/kiva_font_editor.py diff --git a/enable/examples/demo/enable/editors/font_editor.py b/enable/examples/demo/enable/editors/font_editor.py new file mode 100644 index 000000000..b55b5e80f --- /dev/null +++ b/enable/examples/demo/enable/editors/font_editor.py @@ -0,0 +1,46 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +from traits.api import HasStrictTraits +from traitsui.api import View, Item + +from enable.api import Container, TextField, font_trait +from enable.trait_defs.ui.api import KivaFontEditor +from enable.examples._example_support import demo_main + +from kiva.api import Font +from kiva.constants import ITALIC, SWISS, WEIGHT_BOLD +from kiva.trait_defs.api import KivaFont + +size = (500, 200) + +sample_text = "Sphinx of black quartz, judge my vow." + + +class Demo(HasStrictTraits): + """ An example which shows the KivaFontEditor's variations. """ + + font = font_trait(Font("Times", 24, SWISS, WEIGHT_BOLD, ITALIC)) + + view = View( + Item('font', editor=KivaFontEditor(), style='simple', label="Simple"), + Item('font', editor=KivaFontEditor(), style='custom', label="Custom"), + Item('font', editor=KivaFontEditor(), style='text', label="Text"), + Item('font', editor=KivaFontEditor(), style='readonly', label="Readonly"), + Item('font', editor=KivaFontEditor(sample_text=sample_text), style='readonly', label="sample text"), + resizable=True, + width=size[0], + height=size[1], + ) + + +if __name__ == "__main__": + # Save demo so that it doesn't get garbage collected when run within + # existing event loop (i.e. from ipython). + demo = demo_main(Demo, size=size) diff --git a/enable/label.py b/enable/label.py index fe452dca9..0782927fe 100644 --- a/enable/label.py +++ b/enable/label.py @@ -1,4 +1,4 @@ -# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX # All rights reserved. # # This software is provided without warranty under the terms of the BSD @@ -17,7 +17,7 @@ # Enthought library imports from kiva.api import FILL, STROKE from kiva.trait_defs.api import KivaFont -from traits.api import Bool, Enum, Float, HasTraits, Int, List, Str +from traits.api import Bool, Enum, Float, HasTraits, Int, List, Str, observe # Local, relative imports from .colors import black_color_trait, transparent_color_trait @@ -92,11 +92,11 @@ def _calc_line_positions(self, gc): for line in self.text.split("\n")[::-1]: if line != "": ( - width, - height, descent, leading, - ) = gc.get_full_text_extent(line) + width, + height, + ) = gc.get_text_extent(line) if width > max_width: max_width = width new_y_pos = ( @@ -191,25 +191,6 @@ def _draw_mainlayer(self, gc, view_bounds=None, mode="normal"): with gc: gc.translate_ctm(*self.position) - # Draw border and fill background - width, height = self._bounding_box - if self.bgcolor != "transparent": - gc.set_fill_color(self.bgcolor_) - gc.draw_rect((0, 0, width, height), FILL) - if self.border_width > 0: - gc.set_stroke_color(self.border_color_) - gc.set_line_width(self.border_width) - border_offset = (self.border_width - 1) / 2.0 - gc.draw_rect( - ( - border_offset, - border_offset, - width - 2 * border_offset, - height - 2 * border_offset, - ), - STROKE, - ) - gc.set_fill_color(self.color_) gc.set_stroke_color(self.color_) gc.set_font(self.font) @@ -254,3 +235,7 @@ def _text_changed(self): def _rotate_angle_changed(self): self._position_cache_valid = False + + @observe('bounds.items') + def _update_bounds(self, event): + self._position_cache_valid = False diff --git a/enable/tests/trait_defs/test_kiva_font_editor.py b/enable/tests/trait_defs/test_kiva_font_editor.py new file mode 100644 index 000000000..f84968b8c --- /dev/null +++ b/enable/tests/trait_defs/test_kiva_font_editor.py @@ -0,0 +1,184 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" Test the interaction between traitsui and enable's ComponentEditor. +""" +import unittest +from unittest import mock + +from pyface.font import Font as PyfaceFont +import pyface.font_dialog +from traits.api import Any, HasTraits +from traits.testing.api import UnittestTools +from traitsui.api import Item, View + +from kiva.api import Font +from enable.enable_traits import font_trait +from enable.trait_defs.ui.kiva_font_editor import KivaFontEditor +from enable.tests._testing import ( + get_dialog_size, skip_if_null, skip_if_not_qt, skip_if_not_wx +) + +ITEM_WIDTH, ITEM_HEIGHT = 700, 200 + + +class KivaFontView(HasTraits): + """ View containing an item with ComponentEditor. """ + + font = font_trait() + + traits_view = View( + Item("font", editor=KivaFontEditor()), + resizable=True, + ) + + +class TestKivaFontEditor(UnittestTools, unittest.TestCase): + + @skip_if_null + def test_readonly_default_view(self): + obj = KivaFontView() + + ui = obj.edit_traits( + view = View( + Item("font", editor=KivaFontEditor(), style='readonly'), + resizable=True, + ) + ) + try: + # check initial state + editor = ui.info.font + component = editor.component + self.assertIs(editor.value, obj.font) + self.assertIs(editor.font, obj.font) + self.assertIs(component.font, obj.font) + self.assertEqual(editor.str_value, "10 point") + self.assertEqual(component.text, "10 point") + + # check a change + new_font = Font( + face_name="Helvetica", + size=24, + weight=700, + style=2, + ) + + obj.font = new_font + + self.assertIs(editor.value, new_font) + self.assertIs(component.font, new_font) + self.assertEqual(editor.str_value, "24 point Helvetica Bold Italic") + self.assertEqual(component.text, "24 point Helvetica Bold Italic") + finally: + ui.dispose() + + @skip_if_null + def test_simple_default_view(self): + obj = KivaFontView() + + ui = obj.edit_traits( + view = View( + Item("font", editor=KivaFontEditor()), + resizable=True, + ) + ) + try: + editor = ui.info.font + component = editor.component + self.assertIs(editor.value, obj.font) + self.assertIs(editor.font, obj.font) + self.assertIs(component.font, obj.font) + self.assertEqual(editor.str_value, "10 point") + self.assertEqual(component.text, "10 point") + finally: + ui.dispose() + + @skip_if_null + def test_simple_default_object_change(self): + obj = KivaFontView() + + ui = obj.edit_traits( + view = View( + Item("font", editor=KivaFontEditor()), + resizable=True, + ) + ) + try: + editor = ui.info.font + component = editor.component + + new_font = Font( + face_name="Helvetica", + size=24, + weight=700, + style=2, + ) + obj.font = new_font + + self.assertIs(editor.value, new_font) + self.assertIs(editor.font, new_font) + self.assertIs(component.font, new_font) + self.assertEqual(editor.str_value, "24 point Helvetica Bold Italic") + self.assertEqual(component.text, "24 point Helvetica Bold Italic") + finally: + ui.dispose() + + @skip_if_null + def test_simple_default_editor_change(self): + obj = KivaFontView() + + ui = obj.edit_traits( + view = View( + Item("font", editor=KivaFontEditor()), + resizable=True, + ) + ) + try: + editor = ui.info.font + component = editor.component + + new_font = Font( + face_name="Helvetica", + size=24, + weight=700, + style=2, + ) + editor.update_object(new_font) + + self.assertIs(obj.font, new_font) + self.assertIs(editor.value, new_font) + self.assertIs(editor.font, new_font) + self.assertIs(component.font, new_font) + self.assertEqual(editor.str_value, "24 point Helvetica Bold Italic") + self.assertEqual(component.text, "24 point Helvetica Bold Italic") + finally: + ui.dispose() + + @skip_if_null + def test_sample_text(self): + # this is a smoke test + obj = KivaFontView() + + ui = obj.edit_traits( + view = View( + Item( + "font", + editor=KivaFontEditor(sample_text="sample text"), + style='readonly', + ), + resizable=True, + ) + ) + try: + editor = ui.info.font + component = editor.component + self.assertEqual(editor.str_value, "sample text") + self.assertEqual(component.text, "sample text") + finally: + ui.dispose() diff --git a/enable/trait_defs/ui/api.py b/enable/trait_defs/ui/api.py index ffb5971a2..0ae71b3e1 100644 --- a/enable/trait_defs/ui/api.py +++ b/enable/trait_defs/ui/api.py @@ -7,4 +7,6 @@ # is also available online at http://www.enthought.com/licenses/BSD.txt # # Thanks for using Enthought open source! + +from .kiva_font_editor import KivaFontEditor from .rgba_color_editor import RGBAColorEditor diff --git a/enable/trait_defs/ui/editor_with_component.py b/enable/trait_defs/ui/editor_with_component.py new file mode 100644 index 000000000..16dec991d --- /dev/null +++ b/enable/trait_defs/ui/editor_with_component.py @@ -0,0 +1,133 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from traits.api import Bool, Instance, Str, observe +from traitsui.api import BasicEditorFactory, Editor as BaseEditor, toolkit_object + +from enable.component import Component +from enable.label import Label +from enable.window import Window +from enable.enable_traits import font_trait + + +Editor = toolkit_object("editor:Editor") +if not issubclass(Editor, BaseEditor): + Editor = object + + +class EditorWithComponent(Editor): + """Base class for editors which hold a Component that displays the value. + + This is distinct from CompononentEditor in that the value is not the + Component. + """ + + #: The window that displays the component. + window = Instance(Window) + + #: The component that is created by the UI. + component = Instance(Component) + + def init(self, parent): + self.component = self.create_component() + size = self._get_initial_size() + self.window = self.create_window(parent, size) + self.control = self.window.control + self._parent = parent + + def create_component(self): + raise NotImplementedError() + + def create_window(self, parent, size): + window = Window( + parent, + size=size, + component=self.component, + high_resolution=self.factory.high_resolution, + bgcolor='sys_window', + ) + return window + + def dispose(self): + if self.window is not None: + self.window.cleanup() + self.component = None + self.window = None + self._parent = None + super().dispose() + + +class EditorWithLabelComponent(EditorWithComponent): + """A class that creates a Label component. + + By default it displays the string representation of the value. + """ + + #: The font to use for the label. + font = font_trait() + + def create_component(self): + """Creates the label component.""" + component = Label( + hjustify="center", + vjustify="center", + resizable='hv', + text=self.str_value, + font=self.font, + ) + return component + + def update_editor(self): + if self.component is not None: + self.component.text = self.str_value + self.component.invalidate_and_redraw() + + @observe('font') + def update_font(self, event): + if self.component is not None: + self.component.font = self.font + self.component.invalidate_and_redraw() + + def _get_initial_size(self): + width = self.item.width + height = self.item.height + + if width < 0: + width = 200 + if height < 0: + height = 50 + + return width, height + + def set_size_policy(self, direction, resizable, springy, stretch): + """Set the size policy of the editor's component. + + This is only used by the Qt backend. This is always spring and + resizable. + """ + from pyface.qt import QtGui + + policy = self.window.control.sizePolicy() + + if direction == QtGui.QBoxLayout.Direction.LeftToRight: + policy.setHorizontalStretch(stretch) + policy.setHorizontalPolicy(QtGui.QSizePolicy.Policy.Expanding) + policy.setVerticalStretch(stretch) + policy.setVerticalPolicy(QtGui.QSizePolicy.Policy.Expanding) + + else: # TopToBottom + policy.setVerticalStretch(stretch) + policy.setVerticalPolicy(QtGui.QSizePolicy.Policy.Expanding) + policy.setHorizontalStretch(stretch) + policy.setHorizontalPolicy(QtGui.QSizePolicy.Policy.Expanding) + + self.window.control.setSizePolicy(policy) + if self.window.control is not self.control: + super().set_size_policy() diff --git a/enable/trait_defs/ui/kiva_font_editor.py b/enable/trait_defs/ui/kiva_font_editor.py new file mode 100644 index 000000000..177339a11 --- /dev/null +++ b/enable/trait_defs/ui/kiva_font_editor.py @@ -0,0 +1,128 @@ +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from pyface.font import Font as PyfaceFont +from pyface.font_dialog import get_font +from traits.api import Bool, Callable, Instance, Str, observe +from traits.trait_base import SequenceTypes +from traitsui.api import EditorFactory, Editor as BaseEditor, toolkit_object + +from kiva.fonttools.font import Font +import kiva.constants as kc +from enable.component import Component +from enable.label import Label +from enable.tools.button_tool import ButtonTool +from enable.window import Window +from .editor_with_component import EditorWithLabelComponent + + +def face_name(font): + """ Returns a Font's typeface name. + """ + face_name = font.face_name + if isinstance(face_name, SequenceTypes): + face_name = face_name[0] + + return face_name + + +def str_font(font): + """ Returns the text representation of the specified font trait value + """ + + weight = " Bold" if font.is_bold() else "" + style = " Italic" if font.style in kc.italic_styles else "" + underline = " Underline" if font.underline else "" + + return f"{font.size} point {face_name(font)}{weight}{style}{underline}".strip() + + +class ReadOnlyEditor(EditorWithLabelComponent): + """An Editor which displays a label using the font.""" + + def init(self, parent): + self.font = self.value + super().init(parent) + + def update_editor(self): + self.font = self.value + super().update_editor() + + def string_value(self, value, format_func=None): + if self.factory.sample_text: + return self.factory.sample_text + + return super().string_value(value, str_font) + + +class SimpleEditor(ReadOnlyEditor): + """An Editor which displays a label using the font, click for font dialog. + """ + + button = Instance(ButtonTool) + + def create_component(self): + component = super().create_component() + # add a grey border to indicate interactivity + component.border_visible = True + component.border_width = 1 + component.border_color = (0.5, 0.5, 0.5, 1.0) + + # add a button tool to make the label respond to clicks + self.button = ButtonTool(component=component) + component.tools.append(self.button) + return component + + def update_object(self, value): + """Handle changes to the font due to user action. + """ + self.value = value + # force a refresh of the component's settings + self.update_editor() + + @observe('button:clicked') + def button_clicked(self, event): + if self.window is None: + return + pyface_font = PyfaceFont( + family=[self.value.face_name], + weight=str(self.value.weight), + style='italic' if self.value.style in kc.italic_styles else 'normal', + size=self.value.size, + ) + pyface_font = get_font(self.window.control, pyface_font) + if pyface_font is not None: + font = Font( + face_name=pyface_font.family[0], + weight=pyface_font.weight_, + style=kc.ITALIC if pyface_font.style == 'italic' else kc.NORMAL, + size=int(pyface_font.size), + ) + self.update_object(font) + + +class KivaFontEditor(EditorFactory): + """Editor factory for KivaFontEditors + """ + + #: Alternative text to display instead of the font description. + sample_text = Str() + + #: Switch to turn off high resolution rendering if needed. + high_resolution = Bool(True) + + #: The default format func displays a description of the font. + format_func = Callable(str_font) + + def _get_simple_editor_class(self): + return SimpleEditor + + def _get_readonly_editor_class(self): + return ReadOnlyEditor From b0b2447d75c8d38dd021707dd474d254092fd334 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 26 Apr 2022 13:57:31 +0100 Subject: [PATCH 2/3] Improve docstrings and other fixes suggested by code review. --- enable/examples/demo/enable/editors/font_editor.py | 2 +- enable/label.py | 2 +- enable/trait_defs/ui/editor_with_component.py | 3 ++- enable/trait_defs/ui/kiva_font_editor.py | 12 ++++++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/enable/examples/demo/enable/editors/font_editor.py b/enable/examples/demo/enable/editors/font_editor.py index b55b5e80f..aaf5553ed 100644 --- a/enable/examples/demo/enable/editors/font_editor.py +++ b/enable/examples/demo/enable/editors/font_editor.py @@ -16,7 +16,7 @@ from kiva.api import Font from kiva.constants import ITALIC, SWISS, WEIGHT_BOLD -from kiva.trait_defs.api import KivaFont + size = (500, 200) diff --git a/enable/label.py b/enable/label.py index 0782927fe..3e388cdad 100644 --- a/enable/label.py +++ b/enable/label.py @@ -1,4 +1,4 @@ -# (C) Copyright 2005-2021 Enthought, Inc., Austin, TX +# (C) Copyright 2005-2022 Enthought, Inc., Austin, TX # All rights reserved. # # This software is provided without warranty under the terms of the BSD diff --git a/enable/trait_defs/ui/editor_with_component.py b/enable/trait_defs/ui/editor_with_component.py index 16dec991d..25f8af473 100644 --- a/enable/trait_defs/ui/editor_with_component.py +++ b/enable/trait_defs/ui/editor_with_component.py @@ -19,6 +19,7 @@ Editor = toolkit_object("editor:Editor") if not issubclass(Editor, BaseEditor): + # if the toolkit is "null" make these at least instantiatable Editor = object @@ -109,7 +110,7 @@ def _get_initial_size(self): def set_size_policy(self, direction, resizable, springy, stretch): """Set the size policy of the editor's component. - This is only used by the Qt backend. This is always spring and + This is only used by the Qt backend. This is always springy and resizable. """ from pyface.qt import QtGui diff --git a/enable/trait_defs/ui/kiva_font_editor.py b/enable/trait_defs/ui/kiva_font_editor.py index 177339a11..3878798aa 100644 --- a/enable/trait_defs/ui/kiva_font_editor.py +++ b/enable/trait_defs/ui/kiva_font_editor.py @@ -48,14 +48,24 @@ class ReadOnlyEditor(EditorWithLabelComponent): """An Editor which displays a label using the font.""" def init(self, parent): + """Initialize the editor. + + The Label font should match the value for a font editor. + """ self.font = self.value super().init(parent) def update_editor(self): + """Handle the content of the editor changing.""" self.font = self.value super().update_editor() def string_value(self, value, format_func=None): + """Get a string value to display in the editor. + + If the factory provides sample text, use that, otherwise follow the + usual path, but default to using the `str_font` function. + """ if self.factory.sample_text: return self.factory.sample_text @@ -69,6 +79,7 @@ class SimpleEditor(ReadOnlyEditor): button = Instance(ButtonTool) def create_component(self): + """Create and configure the Label component.""" component = super().create_component() # add a grey border to indicate interactivity component.border_visible = True @@ -89,6 +100,7 @@ def update_object(self, value): @observe('button:clicked') def button_clicked(self, event): + """Display a Pyface FontDialog when the button tool is clicked.""" if self.window is None: return pyface_font = PyfaceFont( From 15cdcecc66d7f1336b5011f2949e5785a59ddce6 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Tue, 26 Apr 2022 17:28:02 +0100 Subject: [PATCH 3/3] Fix flake8 issues with imports. --- .../tests/trait_defs/test_kiva_font_editor.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/enable/tests/trait_defs/test_kiva_font_editor.py b/enable/tests/trait_defs/test_kiva_font_editor.py index f84968b8c..50f3362fe 100644 --- a/enable/tests/trait_defs/test_kiva_font_editor.py +++ b/enable/tests/trait_defs/test_kiva_font_editor.py @@ -10,20 +10,16 @@ """ Test the interaction between traitsui and enable's ComponentEditor. """ import unittest -from unittest import mock -from pyface.font import Font as PyfaceFont -import pyface.font_dialog -from traits.api import Any, HasTraits +from traits.api import HasTraits from traits.testing.api import UnittestTools from traitsui.api import Item, View from kiva.api import Font from enable.enable_traits import font_trait from enable.trait_defs.ui.kiva_font_editor import KivaFontEditor -from enable.tests._testing import ( - get_dialog_size, skip_if_null, skip_if_not_qt, skip_if_not_wx -) +from enable.tests._testing import skip_if_null + ITEM_WIDTH, ITEM_HEIGHT = 700, 200 @@ -46,7 +42,7 @@ def test_readonly_default_view(self): obj = KivaFontView() ui = obj.edit_traits( - view = View( + view=View( Item("font", editor=KivaFontEditor(), style='readonly'), resizable=True, ) @@ -83,7 +79,7 @@ def test_simple_default_view(self): obj = KivaFontView() ui = obj.edit_traits( - view = View( + view=View( Item("font", editor=KivaFontEditor()), resizable=True, ) @@ -104,7 +100,7 @@ def test_simple_default_object_change(self): obj = KivaFontView() ui = obj.edit_traits( - view = View( + view=View( Item("font", editor=KivaFontEditor()), resizable=True, ) @@ -134,7 +130,7 @@ def test_simple_default_editor_change(self): obj = KivaFontView() ui = obj.edit_traits( - view = View( + view=View( Item("font", editor=KivaFontEditor()), resizable=True, ) @@ -166,7 +162,7 @@ def test_sample_text(self): obj = KivaFontView() ui = obj.edit_traits( - view = View( + view=View( Item( "font", editor=KivaFontEditor(sample_text="sample text"),