summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/extensions/base.py11
-rw-r--r--lib/extensions/lettering.py236
-rw-r--r--lib/extensions/params.py280
-rw-r--r--lib/extensions/simulate.py4
-rw-r--r--lib/gui/__init__.py3
-rw-r--r--lib/gui/dialogs.py14
-rw-r--r--lib/gui/presets.py183
-rw-r--r--lib/gui/simulator.py (renamed from lib/simulator.py)133
-rw-r--r--lib/lettering/font.py2
-rw-r--r--lib/lettering/font_variant.py3
-rw-r--r--lib/svg/tags.py4
-rw-r--r--lib/utils/__init__.py13
-rw-r--r--lib/utils/dotdict.py30
-rw-r--r--templates/lettering.inx1
14 files changed, 624 insertions, 293 deletions
diff --git a/lib/extensions/base.py b/lib/extensions/base.py
index 279ca396..98673541 100644
--- a/lib/extensions/base.py
+++ b/lib/extensions/base.py
@@ -211,16 +211,15 @@ class InkstitchExtension(inkex.Effect):
# care that it's unique. That defines a "namespace" of element and
# attribute names to disambiguate conflicts with element and
# attribute names other XML namespaces.
- #
- # Updating inkex.NSS here allows us to pass 'inkstitch' into
- # inkex.addNS().
- inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace'
# call the superclass's method first
inkex.Effect.parse(self)
- # This is the only way I could find to add a namespace to an existing
- # element tree at the top without getting ugly prefixes like "ns0".
+ # Add the inkstitch namespace to the SVG. The inkstitch namespace is
+ # added to inkex.NSS in ../svg/tags.py at import time.
+
+ # The below is the only way I could find to add a namespace to an
+ # existing element tree at the top without getting ugly prefixes like "ns0".
inkex.etree.cleanup_namespaces(self.document,
top_nsmap=inkex.NSS,
keep_ns_prefixes=inkex.NSS.keys())
diff --git a/lib/extensions/lettering.py b/lib/extensions/lettering.py
index 0d6629f8..b6d67c0b 100644
--- a/lib/extensions/lettering.py
+++ b/lib/extensions/lettering.py
@@ -1,39 +1,233 @@
+# -*- coding: UTF-8 -*-
+
+from base64 import b64encode, b64decode
+import json
import os
+import sys
+
+import inkex
+import wx
+from ..elements import nodes_to_elements
+from ..gui import PresetsPanel, SimulatorPreview
from ..i18n import _
from ..lettering import Font
-from ..svg.tags import SVG_PATH_TAG, SVG_GROUP_TAG, INKSCAPE_LABEL
-from ..utils import get_bundled_dir
+from ..svg.tags import SVG_PATH_TAG, SVG_GROUP_TAG, INKSCAPE_LABEL, INKSTITCH_LETTERING
+from ..utils import get_bundled_dir, DotDict
from .commands import CommandsExtension
+class LetteringFrame(wx.Frame):
+ def __init__(self, *args, **kwargs):
+ # begin wxGlade: MyFrame.__init__
+ self.group = kwargs.pop('group')
+ self.cancel_hook = kwargs.pop('on_cancel', None)
+ wx.Frame.__init__(self, None, wx.ID_ANY,
+ _("Ink/Stitch Lettering")
+ )
+
+ self.preview = SimulatorPreview(self, target_duration=1)
+ self.presets_panel = PresetsPanel(self)
+
+ self.load_settings()
+
+ # options
+ self.options_box = wx.StaticBox(self, wx.ID_ANY, label=_("Options"))
+
+ self.back_and_forth_checkbox = wx.CheckBox(self, label=_("Stitch lines of text back and forth"))
+ self.back_and_forth_checkbox.SetValue(self.settings.back_and_forth)
+ self.Bind(wx.EVT_CHECKBOX, lambda event: self.on_change("back_and_forth", event))
+
+ # text editor
+ self.text_editor_box = wx.StaticBox(self, wx.ID_ANY, label=_("Text"))
+
+ self.font_chooser = wx.ComboBox(self, wx.ID_ANY)
+ self.update_font_list()
+
+ self.text_editor = wx.TextCtrl(self, style=wx.TE_MULTILINE | wx.TE_DONTWRAP, value=self.settings.text)
+ self.Bind(wx.EVT_TEXT, lambda event: self.on_change("text", event))
+
+ self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel"))
+ self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel)
+ self.Bind(wx.EVT_CLOSE, self.cancel)
+
+ self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit"))
+ self.apply_button.Bind(wx.EVT_BUTTON, self.apply)
+
+ self.__do_layout()
+ # end wxGlade
+
+ def load_settings(self):
+ try:
+ if INKSTITCH_LETTERING in self.group.attrib:
+ self.settings = DotDict(json.loads(b64decode(self.group.get(INKSTITCH_LETTERING))))
+ return
+ except (TypeError, ValueError):
+ pass
+
+ self.settings = DotDict({
+ "text": u"",
+ "back_and_forth": True,
+ "font": "small_font"
+ })
+
+ def save_settings(self):
+ # We base64 encode the string before storing it in an XML attribute.
+ # In theory, lxml should properly html-encode the string, using HTML
+ # entities like 
 as necessary. However, we've found that Inkscape
+ # incorrectly interpolates the HTML entities upon reading the
+ # extension's output, rather than leaving them as is.
+ #
+ # Details:
+ # https://bugs.launchpad.net/inkscape/+bug/1804346
+ self.group.set(INKSTITCH_LETTERING, b64encode(json.dumps(self.settings)))
+
+ def on_change(self, attribute, event):
+ self.settings[attribute] = event.GetEventObject().GetValue()
+ self.preview.update()
+
+ def generate_patches(self, abort_early=None):
+ patches = []
+
+ font_path = os.path.join(get_bundled_dir("fonts"), self.settings.font)
+ font = Font(font_path)
+
+ try:
+ lines = font.render_text(self.settings.text, back_and_forth=self.settings.back_and_forth)
+ self.group[:] = lines
+ elements = nodes_to_elements(self.group.iterdescendants(SVG_PATH_TAG))
+
+ for element in elements:
+ if abort_early and abort_early.is_set():
+ # cancel; settings were updated and we need to start over
+ return []
+
+ patches.extend(element.embroider(None))
+ except SystemExit:
+ raise
+ except Exception:
+ raise
+ # Ignore errors. This can be things like incorrect paths for
+ # satins or division by zero caused by incorrect param values.
+ pass
+
+ return patches
+
+ def update_font_list(self):
+ pass
+
+ def get_preset_data(self):
+ # called by self.presets_panel
+ preset = {}
+ return preset
+
+ def apply_preset_data(self):
+ # called by self.presets_panel
+ return
+
+ def get_preset_suite_name(self):
+ # called by self.presets_panel
+ return "lettering"
+
+ def apply(self, event):
+ self.preview.disable()
+ self.generate_patches()
+ self.save_settings()
+ self.close()
+
+ def close(self):
+ self.preview.close()
+ self.Destroy()
+
+ def cancel(self, event):
+ if self.cancel_hook:
+ self.cancel_hook()
+
+ self.close()
+
+ def __do_layout(self):
+ outer_sizer = wx.BoxSizer(wx.VERTICAL)
+
+ options_sizer = wx.StaticBoxSizer(self.options_box, wx.VERTICAL)
+ options_sizer.Add(self.back_and_forth_checkbox, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT | wx.BOTTOM, 10)
+ outer_sizer.Add(options_sizer, 0, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10)
+
+ text_editor_sizer = wx.StaticBoxSizer(self.text_editor_box, wx.VERTICAL)
+ text_editor_sizer.Add(self.font_chooser, 0, wx.ALL, 10)
+ text_editor_sizer.Add(self.text_editor, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
+ outer_sizer.Add(text_editor_sizer, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10)
+
+ outer_sizer.Add(self.presets_panel, 0, wx.EXPAND | wx.EXPAND | wx.ALL, 10)
+
+ buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
+ buttons_sizer.Add(self.cancel_button, 0, wx.ALIGN_RIGHT | wx.RIGHT, 10)
+ buttons_sizer.Add(self.apply_button, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 10)
+ outer_sizer.Add(buttons_sizer, 0, wx.ALIGN_RIGHT, 10)
+
+ self.SetSizerAndFit(outer_sizer)
+ self.Layout()
+
+ # SetSizerAndFit determined the minimum size that fits all the controls
+ # and set the window's minimum size so that the user can't make it
+ # smaller. It also set the window to that size. We'd like to give the
+ # user a bit more room for text, so we'll add some height.
+ size = self.GetSize()
+ size.height = size.height + 200
+ self.SetSize(size)
+
+
class Lettering(CommandsExtension):
COMMANDS = ["trim"]
def __init__(self, *args, **kwargs):
+ self.cancelled = False
CommandsExtension.__init__(self, *args, **kwargs)
- self.OptionParser.add_option("-t", "--text")
+ def cancel(self):
+ self.cancelled = True
- def effect(self):
- font_path = os.path.join(get_bundled_dir("fonts"), "small_font")
- font = Font(font_path)
- self.ensure_current_layer()
+ def get_or_create_group(self):
+ if self.selected:
+ groups = set()
+
+ for node in self.selected.itervalues():
+ if node.tag == SVG_GROUP_TAG and INKSTITCH_LETTERING in node.attrib:
+ groups.add(node)
+
+ for group in node.iterancestors(SVG_GROUP_TAG):
+ if INKSTITCH_LETTERING in group.attrib:
+ groups.add(group)
- lines = font.render_text(self.options.text.decode('utf-8'))
- self.set_labels(lines)
- self.current_layer.append(lines)
+ if len(groups) > 1:
+ inkex.errormsg(_("Please select only one block of text."))
+ sys.exit(1)
+ elif len(groups) == 0:
+ inkex.errormsg(_("You've selected objects that were not created by the Lettering extension. "
+ "Please clear your selection or select different objects before running Lettering again."))
+ sys.exit(1)
+ else:
+ return list(groups)[0]
+ else:
+ self.ensure_current_layer()
+ return inkex.etree.SubElement(self.current_layer, SVG_GROUP_TAG, {
+ INKSCAPE_LABEL: _("Ink/Stitch Lettering")
+ })
+
+ def effect(self):
+ app = wx.App()
+ frame = LetteringFrame(group=self.get_or_create_group(), on_cancel=self.cancel)
- def set_labels(self, lines):
- path = 1
- for node in lines.iterdescendants():
- if node.tag == SVG_PATH_TAG:
- node.set("id", self.uniqueId("lettering"))
+ # position left, center
+ current_screen = wx.Display.GetFromPoint(wx.GetMousePosition())
+ display = wx.Display(current_screen)
+ display_size = display.GetClientArea()
+ frame_size = frame.GetSize()
+ frame.SetPosition((display_size[0], display_size[3] / 2 - frame_size[1] / 2))
- # L10N Label for an object created by the Lettering extension
- node.set(INKSCAPE_LABEL, _("Lettering %d") % path)
- path += 1
- elif node.tag == SVG_GROUP_TAG:
- node.set("id", self.uniqueId("letteringline"))
+ frame.Show()
+ app.MainLoop()
- # lettering extension already set the label
+ if self.cancelled:
+ # This prevents the superclass from outputting the SVG, because we
+ # may have modified the DOM.
+ sys.exit(0)
diff --git a/lib/extensions/params.py b/lib/extensions/params.py
index 1f3032ca..d3cb154a 100644
--- a/lib/extensions/params.py
+++ b/lib/extensions/params.py
@@ -1,78 +1,20 @@
# -*- coding: UTF-8 -*-
+from collections import defaultdict
+from copy import copy
+from itertools import groupby
import os
import sys
-import json
-import traceback
-from threading import Thread, Event
-from copy import copy
+
import wx
from wx.lib.scrolledpanel import ScrolledPanel
-from collections import defaultdict
-from itertools import groupby
-from .base import InkstitchExtension
-from ..i18n import _
-from ..stitch_plan import patches_to_stitch_plan
+from ..commands import is_command
from ..elements import EmbroideryElement, Fill, AutoFill, Stroke, SatinColumn
+from ..gui import PresetsPanel, SimulatorPreview
+from ..i18n import _
from ..utils import get_resource_dir
-from ..simulator import EmbroiderySimulator
-from ..commands import is_command
-
-
-def presets_path():
- try:
- import appdirs
- config_path = appdirs.user_config_dir('inkstitch')
- except ImportError:
- config_path = os.path.expanduser('~/.inkstitch')
-
- if not os.path.exists(config_path):
- os.makedirs(config_path)
- return os.path.join(config_path, 'presets.json')
-
-
-def load_presets():
- try:
- with open(presets_path(), 'r') as presets:
- presets = json.load(presets)
- return presets
- except IOError:
- return {}
-
-
-def save_presets(presets):
- with open(presets_path(), 'w') as presets_file:
- json.dump(presets, presets_file)
-
-
-def load_preset(name):
- return load_presets().get(name)
-
-
-def save_preset(name, data):
- presets = load_presets()
- presets[name] = data
- save_presets(presets)
-
-
-def delete_preset(name):
- presets = load_presets()
- presets.pop(name, None)
- save_presets(presets)
-
-
-def confirm_dialog(parent, question, caption='ink/stitch'):
- dlg = wx.MessageDialog(parent, question, caption, wx.YES_NO | wx.ICON_QUESTION)
- result = dlg.ShowModal() == wx.ID_YES
- dlg.Destroy()
- return result
-
-
-def info_dialog(parent, message, caption='ink/stitch'):
- dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION)
- dlg.ShowModal()
- dlg.Destroy()
+from .base import InkstitchExtension
class ParamsTab(ScrolledPanel):
@@ -358,7 +300,7 @@ class ParamsTab(ScrolledPanel):
self.changed_inputs.add(self.param_inputs[param])
- if self.on_change_hook():
+ if self.on_change_hook:
self.on_change_hook(self)
# end of class SatinPane
@@ -376,33 +318,10 @@ class SettingsFrame(wx.Frame):
self.tabs = self.tabs_factory(self.notebook)
for tab in self.tabs:
- tab.on_change(self.update_simulator)
-
- self.simulate_window = None
- self.simulate_thread = None
- self.simulate_refresh_needed = Event()
-
- # used when closing to avoid having the window reopen at the last second
- self.disable_simulate_window = False
+ tab.on_change(self.update_preview)
- wx.CallLater(1000, self.update_simulator)
-
- self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets"))
-
- self.preset_chooser = wx.ComboBox(self, wx.ID_ANY)
- self.update_preset_list()
-
- self.load_preset_button = wx.Button(self, wx.ID_ANY, _("Load"))
- self.load_preset_button.Bind(wx.EVT_BUTTON, self.load_preset)
-
- self.add_preset_button = wx.Button(self, wx.ID_ANY, _("Add"))
- self.add_preset_button.Bind(wx.EVT_BUTTON, self.add_preset)
-
- self.overwrite_preset_button = wx.Button(self, wx.ID_ANY, _("Overwrite"))
- self.overwrite_preset_button.Bind(wx.EVT_BUTTON, self.overwrite_preset)
-
- self.delete_preset_button = wx.Button(self, wx.ID_ANY, _("Delete"))
- self.delete_preset_button.Bind(wx.EVT_BUTTON, self.delete_preset)
+ self.preview = SimulatorPreview(self)
+ self.presets_panel = PresetsPanel(self)
self.cancel_button = wx.Button(self, wx.ID_ANY, _("Cancel"))
self.cancel_button.Bind(wx.EVT_BUTTON, self.cancel)
@@ -414,83 +333,17 @@ class SettingsFrame(wx.Frame):
self.apply_button = wx.Button(self, wx.ID_ANY, _("Apply and Quit"))
self.apply_button.Bind(wx.EVT_BUTTON, self.apply)
- self.__set_properties()
+ self.notebook.SetMinSize((800, 600))
+
self.__do_layout()
# end wxGlade
- def update_simulator(self, tab=None):
- if self.simulate_window:
- self.simulate_window.stop()
- self.simulate_window.clear()
-
- if self.disable_simulate_window:
- return
+ def update_preview(self, tab):
+ self.preview.update()
- if not self.simulate_thread or not self.simulate_thread.is_alive():
- self.simulate_thread = Thread(target=self.simulate_worker)
- self.simulate_thread.daemon = True
- self.simulate_thread.start()
+ def generate_patches(self, abort_early):
+ # called by self.preview
- self.simulate_refresh_needed.set()
-
- def simulate_worker(self):
- while True:
- self.simulate_refresh_needed.wait()
- self.simulate_refresh_needed.clear()
- self.update_patches()
-
- def update_patches(self):
- patches = self.generate_patches()
-
- if patches and not self.simulate_refresh_needed.is_set():
- wx.CallAfter(self.refresh_simulator, patches)
-
- def refresh_simulator(self, patches):
- stitch_plan = patches_to_stitch_plan(patches)
- if self.simulate_window:
- self.simulate_window.stop()
- self.simulate_window.load(stitch_plan)
- else:
- params_rect = self.GetScreenRect()
- simulator_pos = params_rect.GetTopRight()
- simulator_pos.x += 5
-
- current_screen = wx.Display.GetFromPoint(wx.GetMousePosition())
- display = wx.Display(current_screen)
- screen_rect = display.GetClientArea()
- simulator_pos.y = screen_rect.GetTop()
-
- width = screen_rect.GetWidth() - params_rect.GetWidth()
- height = screen_rect.GetHeight()
-
- try:
- self.simulate_window = EmbroiderySimulator(None, -1, _("Preview"),
- simulator_pos,
- size=(width, height),
- stitch_plan=stitch_plan,
- on_close=self.simulate_window_closed,
- target_duration=5)
- except Exception:
- error = traceback.format_exc()
-
- try:
- # a window may have been created, so we need to destroy it
- # or the app will never exit
- wx.Window.FindWindowByName(_("Preview")).Destroy()
- except Exception:
- pass
-
- info_dialog(self, error, _("Internal Error"))
-
- self.simulate_window.Show()
- wx.CallLater(10, self.Raise)
-
- wx.CallAfter(self.simulate_window.go)
-
- def simulate_window_closed(self):
- self.simulate_window = None
-
- def generate_patches(self):
patches = []
nodes = []
@@ -505,7 +358,7 @@ class SettingsFrame(wx.Frame):
try:
for node in nodes:
- if self.simulate_refresh_needed.is_set():
+ if abort_early.is_set():
# cancel; params were updated and we need to start over
return []
@@ -523,27 +376,9 @@ class SettingsFrame(wx.Frame):
return patches
- def update_preset_list(self):
- preset_names = load_presets().keys()
- preset_names = [preset for preset in preset_names if preset != "__LAST__"]
- self.preset_chooser.SetItems(sorted(preset_names))
-
- def get_preset_name(self):
- preset_name = self.preset_chooser.GetValue().strip()
- if preset_name:
- return preset_name
- else:
- info_dialog(self, _("Please enter or select a preset name first."), caption=_('Preset'))
- return
-
- def check_and_load_preset(self, preset_name):
- preset = load_preset(preset_name)
- if not preset:
- info_dialog(self, _('Preset "%s" not found.') % preset_name, caption=_('Preset'))
-
- return preset
-
def get_preset_data(self):
+ # called by self.presets_panel
+
preset = {}
current_tab = self.tabs[self.notebook.GetSelection()]
@@ -561,53 +396,13 @@ class SettingsFrame(wx.Frame):
return preset
- def add_preset(self, event, overwrite=False):
- preset_name = self.get_preset_name()
- if not preset_name:
- return
-
- if not overwrite and load_preset(preset_name):
- info_dialog(self, _('Preset "%s" already exists. Please use another name or press "Overwrite"') % preset_name, caption=_('Preset'))
-
- save_preset(preset_name, self.get_preset_data())
- self.update_preset_list()
-
- event.Skip()
-
- def overwrite_preset(self, event):
- self.add_preset(event, overwrite=True)
-
- def _load_preset(self, preset_name):
- preset = self.check_and_load_preset(preset_name)
- if not preset:
- return
+ def apply_preset_data(self, preset_data):
+ # called by self.presets_panel
for tab in self.tabs:
- tab.load_preset(preset)
-
- def load_preset(self, event):
- preset_name = self.get_preset_name()
- if not preset_name:
- return
+ tab.load_preset(preset_data)
- self._load_preset(preset_name)
-
- event.Skip()
-
- def delete_preset(self, event):
- preset_name = self.get_preset_name()
- if not preset_name:
- return
-
- preset = self.check_and_load_preset(preset_name)
- if not preset:
- return
-
- delete_preset(preset_name)
- self.update_preset_list()
- self.preset_chooser.SetValue("")
-
- event.Skip()
+ self.preview.update()
def _apply(self):
for tab in self.tabs:
@@ -615,19 +410,16 @@ class SettingsFrame(wx.Frame):
def apply(self, event):
self._apply()
- save_preset("__LAST__", self.get_preset_data())
+ self.presets_panel.store_preset("__LAST__", self.get_preset_data())
self.close()
def use_last(self, event):
- self.disable_simulate_window = True
- self._load_preset("__LAST__")
+ self.preview.disable()
+ self.presets_panel.load_preset("__LAST__")
self.apply(event)
def close(self):
- if self.simulate_window:
- self.simulate_window.stop()
- self.simulate_window.Close()
-
+ self.preview.close()
self.Destroy()
def cancel(self, event):
@@ -636,27 +428,15 @@ class SettingsFrame(wx.Frame):
self.close()
- def __set_properties(self):
- # begin wxGlade: MyFrame.__set_properties
- self.notebook.SetMinSize((800, 600))
- self.preset_chooser.SetSelection(-1)
- # end wxGlade
-
def __do_layout(self):
# begin wxGlade: MyFrame.__do_layout
sizer_1 = wx.BoxSizer(wx.VERTICAL)
# self.sizer_3_staticbox.Lower()
- sizer_2 = wx.StaticBoxSizer(self.presets_box, wx.HORIZONTAL)
sizer_3 = wx.BoxSizer(wx.HORIZONTAL)
for tab in self.tabs:
self.notebook.AddPage(tab, tab.name)
sizer_1.Add(self.notebook, 1, wx.EXPAND | wx.LEFT | wx.TOP | wx.RIGHT, 10)
- sizer_2.Add(self.preset_chooser, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5)
- sizer_2.Add(self.load_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5)
- sizer_2.Add(self.add_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5)
- sizer_2.Add(self.overwrite_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5)
- sizer_2.Add(self.delete_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 5)
- sizer_1.Add(sizer_2, 0, flag=wx.EXPAND | wx.ALL, border=10)
+ sizer_1.Add(self.presets_panel, 0, flag=wx.EXPAND | wx.ALL, border=10)
sizer_3.Add(self.cancel_button, 0, wx.ALIGN_RIGHT | wx.RIGHT, 5)
sizer_3.Add(self.use_last_button, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5)
sizer_3.Add(self.apply_button, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5)
diff --git a/lib/extensions/simulate.py b/lib/extensions/simulate.py
index f962b206..a4da26d5 100644
--- a/lib/extensions/simulate.py
+++ b/lib/extensions/simulate.py
@@ -1,6 +1,6 @@
-from .base import InkstitchExtension
-from ..simulator import show_simulator
+from ..gui import show_simulator
from ..stitch_plan import patches_to_stitch_plan
+from .base import InkstitchExtension
class Simulate(InkstitchExtension):
diff --git a/lib/gui/__init__.py b/lib/gui/__init__.py
new file mode 100644
index 00000000..060c3d93
--- /dev/null
+++ b/lib/gui/__init__.py
@@ -0,0 +1,3 @@
+from dialogs import info_dialog, confirm_dialog
+from presets import PresetsPanel
+from simulator import EmbroiderySimulator, SimulatorPreview, show_simulator
diff --git a/lib/gui/dialogs.py b/lib/gui/dialogs.py
new file mode 100644
index 00000000..c09503b3
--- /dev/null
+++ b/lib/gui/dialogs.py
@@ -0,0 +1,14 @@
+import wx
+
+
+def confirm_dialog(parent, question, caption='ink/stitch'):
+ dlg = wx.MessageDialog(parent, question, caption, wx.YES_NO | wx.ICON_QUESTION)
+ result = dlg.ShowModal() == wx.ID_YES
+ dlg.Destroy()
+ return result
+
+
+def info_dialog(parent, message, caption='ink/stitch'):
+ dlg = wx.MessageDialog(parent, message, caption, wx.OK | wx.ICON_INFORMATION)
+ dlg.ShowModal()
+ dlg.Destroy()
diff --git a/lib/gui/presets.py b/lib/gui/presets.py
new file mode 100644
index 00000000..e6000718
--- /dev/null
+++ b/lib/gui/presets.py
@@ -0,0 +1,183 @@
+import json
+import os
+import re
+
+import wx
+
+from ..i18n import _
+from ..utils import cache
+from .dialogs import info_dialog
+
+
+class PresetsPanel(wx.Panel):
+ """A wx.Panel for loading, saving, and applying presets.
+
+ A preset is a named collection of settings. From the perspective of this
+ class, a preset is an opaque JSON-serializable object.
+
+ The PresetsPanel will handle interaction with the user and inform the
+ instantiator of events such as a preset being loaded. Presets starting
+ and ending with "__" will not be shown to the user. This allows for the
+ instantiator to manage hidden presets such as "__LAST__".
+ """
+
+ HIDDEN_PRESET_RE = re.compile('^__.*__$')
+
+ def __init__(self, parent, *args, **kwargs):
+ """Construct a PresetsPanel.
+
+ The parent is the parent window for this wx.Panel. The parent is
+ expected to implement the following methods:
+
+ def get_preset_data(self)
+ returns a JSON object representing the current state as a preset
+
+ def apply_preset_data(self, preset_data):
+ apply the preset data to the GUI, updating GUI elements as necessary
+
+ def get_preset_suite_name(self):
+ Return a string used in the presets filename, e.g. "lettering" -> "lettering_presets.json".
+ If not defined, "presets.json" will be used.
+ """
+
+ kwargs.setdefault('style', wx.BORDER_NONE)
+ wx.Panel.__init__(self, parent, wx.ID_ANY, *args, **kwargs)
+ self.parent = parent
+
+ self.presets_box = wx.StaticBox(self, wx.ID_ANY, label=_("Presets"))
+
+ self.preset_chooser = wx.ComboBox(self, wx.ID_ANY)
+ self.update_preset_list()
+ self.preset_chooser.SetSelection(-1)
+
+ self.load_preset_button = wx.Button(self, wx.ID_ANY, _("Load"))
+ self.load_preset_button.Bind(wx.EVT_BUTTON, self.load_selected_preset)
+
+ self.add_preset_button = wx.Button(self, wx.ID_ANY, _("Add"))
+ self.add_preset_button.Bind(wx.EVT_BUTTON, self.add_preset)
+
+ self.overwrite_preset_button = wx.Button(self, wx.ID_ANY, _("Overwrite"))
+ self.overwrite_preset_button.Bind(wx.EVT_BUTTON, self.overwrite_preset)
+
+ self.delete_preset_button = wx.Button(self, wx.ID_ANY, _("Delete"))
+ self.delete_preset_button.Bind(wx.EVT_BUTTON, self.delete_preset)
+
+ presets_sizer = wx.StaticBoxSizer(self.presets_box, wx.HORIZONTAL)
+ presets_sizer.Add(self.preset_chooser, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10)
+ presets_sizer.Add(self.load_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10)
+ presets_sizer.Add(self.add_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10)
+ presets_sizer.Add(self.overwrite_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10)
+ presets_sizer.Add(self.delete_preset_button, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 10)
+
+ self.SetSizerAndFit(presets_sizer)
+ self.Layout()
+
+ @property
+ @cache
+ def suite_name(self):
+ try:
+ return self.parent.get_preset_suite_name() + "_presets"
+ except AttributeError:
+ return "presets"
+
+ @cache
+ def presets_path(self):
+ try:
+ import appdirs
+ config_path = appdirs.user_config_dir('inkstitch')
+ except ImportError:
+ config_path = os.path.expanduser('~/.inkstitch')
+
+ if not os.path.exists(config_path):
+ os.makedirs(config_path)
+ return os.path.join(config_path, '%s.json' % self.suite_name)
+
+ def _load_presets(self):
+ try:
+ with open(self.presets_path(), 'r') as presets:
+ presets = json.load(presets)
+ return presets
+ except IOError:
+ return {}
+
+ def _save_presets(self, presets):
+ with open(self.presets_path(), 'w') as presets_file:
+ json.dump(presets, presets_file)
+
+ def update_preset_list(self):
+ preset_names = self._load_presets().keys()
+ preset_names = [preset for preset in preset_names if not self.is_hidden(preset)]
+ self.preset_chooser.SetItems(sorted(preset_names))
+
+ def is_hidden(self, preset_name):
+ return self.HIDDEN_PRESET_RE.match(preset_name)
+
+ def get_preset_name(self):
+ preset_name = self.preset_chooser.GetValue().strip()
+ if preset_name:
+ return preset_name
+ else:
+ info_dialog(self, _("Please enter or select a preset name first."), caption=_('Preset'))
+ return
+
+ def check_and_load_preset(self, preset_name):
+ preset = self._load_presets().get(preset_name)
+ if not preset:
+ info_dialog(self, _('Preset "%s" not found.') % preset_name, caption=_('Preset'))
+
+ return preset
+
+ def store_preset(self, preset_name, data):
+ presets = self._load_presets()
+ presets[preset_name] = data
+ self._save_presets(presets)
+ self.update_preset_list()
+
+ def add_preset(self, event, overwrite=False):
+ preset_name = self.get_preset_name()
+ if not preset_name:
+ return
+
+ if not overwrite and preset_name in self._load_presets():
+ info_dialog(self, _('Preset "%s" already exists. Please use another name or press "Overwrite"') % preset_name, caption=_('Preset'))
+
+ self.store_preset(self, preset_name, self.parent.get_preset_data())
+
+ event.Skip()
+
+ def overwrite_preset(self, event):
+ self.add_preset(event, overwrite=True)
+
+ def load_preset(self, preset_name):
+ preset = self.check_and_load_preset(preset_name)
+ if not preset:
+ return
+
+ self.parent.apply_preset_data(preset)
+
+ def load_selected_preset(self, event):
+ preset_name = self.get_preset_name()
+ if not preset_name:
+ return
+
+ self.load_preset(preset_name)
+
+ event.Skip()
+
+ def delete_preset(self, event):
+ preset_name = self.get_preset_name()
+ if not preset_name:
+ return
+
+ preset = self.check_and_load_preset(preset_name)
+ if not preset:
+ return
+
+ presets = self._load_presets()
+ presets.pop(preset_name, None)
+ self._save_presets(presets)
+
+ self.update_preset_list()
+ self.preset_chooser.SetValue("")
+
+ event.Skip()
diff --git a/lib/simulator.py b/lib/gui/simulator.py
index 34d12887..0eed18c9 100644
--- a/lib/simulator.py
+++ b/lib/gui/simulator.py
@@ -1,12 +1,20 @@
+from itertools import izip
import sys
+from threading import Thread, Event
+import time
+import traceback
+
import wx
from wx.lib.intctrl import IntCtrl
-import time
-from itertools import izip
-from .svg import PIXELS_PER_MM
-from .i18n import _
-from .stitch_plan import stitch_plan_from_file
+from ..i18n import _
+from ..stitch_plan import stitch_plan_from_file, patches_to_stitch_plan
+
+from ..svg import PIXELS_PER_MM
+
+
+from .dialogs import info_dialog
+
# L10N command label at bottom of simulator window
COMMAND_NAMES = [_("STITCH"), _("JUMP"), _("TRIM"), _("STOP"), _("COLOR CHANGE")]
@@ -636,6 +644,121 @@ class EmbroiderySimulator(wx.Frame):
self.simulator_panel.clear()
+class SimulatorPreview(Thread):
+ """Manages a preview simulation and a background thread for generating patches."""
+
+ def __init__(self, parent, *args, **kwargs):
+ """Construct a SimulatorPreview.
+
+ The parent is expected to be a wx.Window and also implement the following methods:
+
+ def generate_patches(self, abort_event):
+ Produce an list of Patch instances. This method will be
+ invoked in a background thread and it is expected that it may
+ take awhile.
+
+ If possible, this method should periodically check
+ abort_event.is_set(), and if True, stop early. The return
+ value will be ignored in this case.
+ """
+ self.parent = parent
+ self.target_duration = kwargs.pop('target_duration', 5)
+ super(SimulatorPreview, self).__init__(*args, **kwargs)
+ self.daemon = True
+
+ self.simulate_window = None
+ self.refresh_needed = Event()
+
+ # used when closing to avoid having the window reopen at the last second
+ self._disabled = False
+
+ wx.CallLater(1000, self.update)
+
+ def disable(self):
+ self._disabled = True
+
+ def update(self):
+ """Request an update of the simulator preview with freshly-generated patches."""
+
+ if self.simulate_window:
+ self.simulate_window.stop()
+ self.simulate_window.clear()
+
+ if self._disabled:
+ return
+
+ if not self.is_alive():
+ self.start()
+
+ self.refresh_needed.set()
+
+ def run(self):
+ while True:
+ self.refresh_needed.wait()
+ self.refresh_needed.clear()
+ self.update_patches()
+
+ def update_patches(self):
+ patches = self.parent.generate_patches(self.refresh_needed)
+
+ if patches and not self.refresh_needed.is_set():
+ stitch_plan = patches_to_stitch_plan(patches)
+
+ # GUI stuff needs to happen in the main thread, so we ask the main
+ # thread to call refresh_simulator().
+ wx.CallAfter(self.refresh_simulator, patches, stitch_plan)
+
+ def refresh_simulator(self, patches, stitch_plan):
+ if self.simulate_window:
+ self.simulate_window.stop()
+ self.simulate_window.load(stitch_plan)
+ else:
+ params_rect = self.parent.GetScreenRect()
+ simulator_pos = params_rect.GetTopRight()
+ simulator_pos.x += 5
+
+ current_screen = wx.Display.GetFromPoint(wx.GetMousePosition())
+ display = wx.Display(current_screen)
+ screen_rect = display.GetClientArea()
+ simulator_pos.y = screen_rect.GetTop()
+
+ width = screen_rect.GetWidth() - params_rect.GetWidth()
+ height = screen_rect.GetHeight()
+
+ try:
+ self.simulate_window = EmbroiderySimulator(None, -1, _("Preview"),
+ simulator_pos,
+ size=(width, height),
+ stitch_plan=stitch_plan,
+ on_close=self.simulate_window_closed,
+ target_duration=self.target_duration)
+ except Exception:
+ error = traceback.format_exc()
+
+ try:
+ # a window may have been created, so we need to destroy it
+ # or the app will never exit
+ wx.Window.FindWindowByName(_("Preview")).Destroy()
+ except Exception:
+ pass
+
+ info_dialog(self, error, _("Internal Error"))
+
+ self.simulate_window.Show()
+ wx.CallLater(10, self.parent.Raise)
+
+ wx.CallAfter(self.simulate_window.go)
+
+ def simulate_window_closed(self):
+ self.simulate_window = None
+
+ def close(self):
+ self.disable()
+ if self.simulate_window:
+ self.simulate_window.stop()
+ self.simulate_window.Close()
+
+
def show_simulator(stitch_plan):
app = wx.App()
current_screen = wx.Display.GetFromPoint(wx.GetMousePosition())
diff --git a/lib/lettering/font.py b/lib/lettering/font.py
index 4a89df47..9d0389a0 100644
--- a/lib/lettering/font.py
+++ b/lib/lettering/font.py
@@ -106,7 +106,7 @@ class Font(object):
position.x = 0
position.y += self.leading
- if self.auto_satin:
+ if self.auto_satin and len(line_group) > 0:
self._apply_auto_satin(line_group)
return line_group
diff --git a/lib/lettering/font_variant.py b/lib/lettering/font_variant.py
index 445946e2..d826dca6 100644
--- a/lib/lettering/font_variant.py
+++ b/lib/lettering/font_variant.py
@@ -53,7 +53,8 @@ class FontVariant(object):
def _load_glyphs(self):
svg_path = os.path.join(self.path, u"%s.svg" % self.variant)
- svg = inkex.etree.parse(svg_path)
+ with open(svg_path) as svg_file:
+ svg = inkex.etree.parse(svg_file)
glyph_layers = svg.xpath(".//svg:g[starts-with(@inkscape:label, 'GlyphLayer-')]", namespaces=inkex.NSS)
for layer in glyph_layers:
diff --git a/lib/svg/tags.py b/lib/svg/tags.py
index 55352be2..84509f90 100644
--- a/lib/svg/tags.py
+++ b/lib/svg/tags.py
@@ -1,5 +1,8 @@
import inkex
+# This is used below and added to the document in ../extensions/base.py.
+inkex.NSS['inkstitch'] = 'http://inkstitch.org/namespace'
+
SVG_PATH_TAG = inkex.addNS('path', 'svg')
SVG_POLYLINE_TAG = inkex.addNS('polyline', 'svg')
@@ -16,5 +19,6 @@ CONNECTOR_TYPE = inkex.addNS('connector-type', 'inkscape')
XLINK_HREF = inkex.addNS('href', 'xlink')
SODIPODI_NAMEDVIEW = inkex.addNS('namedview', 'sodipodi')
SODIPODI_GUIDE = inkex.addNS('guide', 'sodipodi')
+INKSTITCH_LETTERING = inkex.addNS('lettering', 'inkstitch')
EMBROIDERABLE_TAGS = (SVG_PATH_TAG, SVG_POLYLINE_TAG)
diff --git a/lib/utils/__init__.py b/lib/utils/__init__.py
index a6ae4374..2b39da71 100644
--- a/lib/utils/__init__.py
+++ b/lib/utils/__init__.py
@@ -1,6 +1,7 @@
-from geometry import *
-from cache import cache
-from io import *
-from inkscape import *
-from paths import *
-from string import *
+from .cache import cache
+from .dotdict import DotDict
+from .geometry import *
+from .inkscape import *
+from .io import *
+from .paths import *
+from .string import *
diff --git a/lib/utils/dotdict.py b/lib/utils/dotdict.py
new file mode 100644
index 00000000..1ab3a4fe
--- /dev/null
+++ b/lib/utils/dotdict.py
@@ -0,0 +1,30 @@
+class DotDict(dict):
+ """A dict subclass that allows accessing methods using dot notation.
+
+ adapted from: https://stackoverflow.com/questions/13520421/recursive-dotdict
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(DotDict, self).__init__(*args, **kwargs)
+
+ for k, v in self.iteritems():
+ if isinstance(v, dict):
+ self[k] = DotDict(v)
+
+ __setattr__ = dict.__setitem__
+ __delattr__ = dict.__delitem__
+
+ def __getattr__(self, name):
+ if name.startswith('_'):
+ raise AttributeError("'DotDict' object has no attribute '%s'" % name)
+
+ if name in self:
+ return self.__getitem__(name)
+ else:
+ new_dict = DotDict()
+ self.__setitem__(name, new_dict)
+ return new_dict
+
+ def __repr__(self):
+ super_repr = super(DotDict, self).__repr__()
+ return "DotDict(%s)" % super_repr
diff --git a/templates/lettering.inx b/templates/lettering.inx
index cc34da8c..88d683ef 100644
--- a/templates/lettering.inx
+++ b/templates/lettering.inx
@@ -4,7 +4,6 @@
<id>org.inkstitch.lettering.{{ locale }}</id>
<dependency type="executable" location="extensions">inkstitch.py</dependency>
<dependency type="executable" location="extensions">inkex.py</dependency>
- <param name="text" type="string" _gui-text="{% trans %}Text{% endtrans %}"></param>
<param name="extension" type="string" gui-hidden="true">lettering</param>
<effect>
<object-type>all</object-type>