diff -r 577118ebd179 -r c89fc366bebd svghmi/ui.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/svghmi/ui.py Thu Sep 02 21:36:29 2021 +0200 @@ -0,0 +1,700 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# This file is part of Beremiz +# Copyright (C) 2021: Edouard TISSERANT +# +# See COPYING file for copyrights details. + +from __future__ import absolute_import +import os +import hashlib +import weakref +import re +from threading import Thread, Lock +from functools import reduce +from itertools import izip +from operator import or_ +from tempfile import NamedTemporaryFile + +import wx +from wx.lib.scrolledpanel import ScrolledPanel + +from lxml import etree +from lxml.etree import XSLTApplyError +from XSLTransform import XSLTransform + +import util.paths as paths +from IDEFrame import EncodeFileSystemPath, DecodeFileSystemPath +from docutil import get_inkscape_path + +from util.ProcessLogger import ProcessLogger + +ScriptDirectory = paths.AbsDir(__file__) + +HMITreeDndMagicWord = "text/beremiz-hmitree" + +class HMITreeSelector(wx.TreeCtrl): + def __init__(self, parent): + + wx.TreeCtrl.__init__(self, parent, style=( + wx.TR_MULTIPLE | + wx.TR_HAS_BUTTONS | + wx.SUNKEN_BORDER | + wx.TR_LINES_AT_ROOT)) + + self.ordered_items = [] + self.parent = parent + + self.MakeTree() + + self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnTreeNodeSelection) + self.Bind(wx.EVT_TREE_BEGIN_DRAG, self.OnTreeBeginDrag) + + def _recurseTree(self, current_hmitree_root, current_tc_root): + for c in current_hmitree_root.children: + if hasattr(c, "children"): + display_name = ('{} (class={})'.format(c.name, c.hmiclass)) \ + if c.hmiclass is not None else c.name + tc_child = self.AppendItem(current_tc_root, display_name) + self.SetPyData(tc_child, c) + + self._recurseTree(c,tc_child) + else: + display_name = '{} {}'.format(c.nodetype[4:], c.name) + tc_child = self.AppendItem(current_tc_root, display_name) + self.SetPyData(tc_child, c) + + def OnTreeNodeSelection(self, event): + items = self.GetSelections() + items_pydata = [self.GetPyData(item) for item in items] + + # append new items to ordered item list + for item_pydata in items_pydata: + if item_pydata not in self.ordered_items: + self.ordered_items.append(item_pydata) + + # filter out vanished items + self.ordered_items = [ + item_pydata + for item_pydata in self.ordered_items + if item_pydata in items_pydata] + + self.parent.OnHMITreeNodeSelection(self.ordered_items) + + def OnTreeBeginDrag(self, event): + """ + Called when a drag is started in tree + @param event: wx.TreeEvent + """ + if self.ordered_items: + # Just send a recognizable mime-type, drop destination + # will get python data from parent + data = wx.CustomDataObject(HMITreeDndMagicWord) + dragSource = wx.DropSource(self) + dragSource.SetData(data) + dragSource.DoDragDrop() + + def MakeTree(self, hmi_tree_root=None): + + self.Freeze() + + self.root = None + self.DeleteAllItems() + + root_display_name = _("Please build to see HMI Tree") \ + if hmi_tree_root is None else "HMI" + self.root = self.AddRoot(root_display_name) + self.SetPyData(self.root, hmi_tree_root) + + if hmi_tree_root is not None: + self._recurseTree(hmi_tree_root, self.root) + self.Expand(self.root) + + self.Thaw() + +class WidgetPicker(wx.TreeCtrl): + def __init__(self, parent, initialdir=None): + wx.TreeCtrl.__init__(self, parent, style=( + wx.TR_MULTIPLE | + wx.TR_HAS_BUTTONS | + wx.SUNKEN_BORDER | + wx.TR_LINES_AT_ROOT)) + + self.MakeTree(initialdir) + + def _recurseTree(self, current_dir, current_tc_root, dirlist): + """ + recurse through subdirectories, but creates tree nodes + only when (sub)directory conbtains .svg file + """ + res = [] + for f in sorted(os.listdir(current_dir)): + p = os.path.join(current_dir,f) + if os.path.isdir(p): + + r = self._recurseTree(p, current_tc_root, dirlist + [f]) + if len(r) > 0 : + res = r + dirlist = [] + current_tc_root = res.pop() + + elif os.path.splitext(f)[1].upper() == ".SVG": + if len(dirlist) > 0 : + res = [] + for d in dirlist: + current_tc_root = self.AppendItem(current_tc_root, d) + res.append(current_tc_root) + self.SetPyData(current_tc_root, None) + dirlist = [] + res.pop() + tc_child = self.AppendItem(current_tc_root, f) + self.SetPyData(tc_child, p) + return res + + def MakeTree(self, lib_dir = None): + + self.Freeze() + + self.root = None + self.DeleteAllItems() + + root_display_name = _("Please select widget library directory") \ + if lib_dir is None else os.path.basename(lib_dir) + self.root = self.AddRoot(root_display_name) + self.SetPyData(self.root, None) + + if lib_dir is not None and os.path.exists(lib_dir): + self._recurseTree(lib_dir, self.root, []) + self.Expand(self.root) + + self.Thaw() + +class PathDropTarget(wx.DropTarget): + + def __init__(self, parent): + data = wx.CustomDataObject(HMITreeDndMagicWord) + wx.DropTarget.__init__(self, data) + self.ParentWindow = parent + + def OnDrop(self, x, y): + self.ParentWindow.OnHMITreeDnD() + return True + +class ParamEditor(wx.Panel): + def __init__(self, parent, paramdesc): + wx.Panel.__init__(self, parent.main_panel) + label = paramdesc.get("name")+ ": " + paramdesc.get("accepts") + if paramdesc.text: + label += "\n\"" + paramdesc.text + "\"" + self.desc = wx.StaticText(self, label=label) + self.valid_bmp = wx.ArtProvider.GetBitmap(wx.ART_TICK_MARK, wx.ART_TOOLBAR, (16,16)) + self.invalid_bmp = wx.ArtProvider.GetBitmap(wx.ART_CROSS_MARK, wx.ART_TOOLBAR, (16,16)) + self.validity_sbmp = wx.StaticBitmap(self, -1, self.invalid_bmp) + self.edit = wx.TextCtrl(self) + self.edit_sizer = wx.FlexGridSizer(cols=2, hgap=0, rows=1, vgap=0) + self.edit_sizer.AddGrowableCol(0) + self.edit_sizer.AddGrowableRow(0) + self.edit_sizer.Add(self.edit, flag=wx.GROW) + self.edit_sizer.Add(self.validity_sbmp, flag=wx.GROW) + self.main_sizer = wx.BoxSizer(wx.VERTICAL) + self.main_sizer.Add(self.desc, flag=wx.GROW) + self.main_sizer.Add(self.edit_sizer, flag=wx.GROW) + self.SetSizer(self.main_sizer) + self.main_sizer.Fit(self) + + def GetValue(self): + return self.edit.GetValue() + + def setValidity(self, validity): + if validity is not None: + bmp = self.valid_bmp if validity else self.invalid_bmp + self.validity_sbmp.SetBitmap(bmp) + self.validity_sbmp.Show(True) + else : + self.validity_sbmp.Show(False) + +models = { typename: re.compile(regex) for typename, regex in [ + ("string", r".*"), + ("int", r"^-?([1-9][0-9]*|0)$"), + ("real", r"^-?([1-9][0-9]*|0)(\.[0-9]+)?$")]} + +class ArgEditor(ParamEditor): + def __init__(self, parent, argdesc, prefillargdesc): + ParamEditor.__init__(self, parent, argdesc) + self.ParentObj = parent + self.argdesc = argdesc + self.Bind(wx.EVT_TEXT, self.OnArgChanged, self.edit) + prefill = "" if prefillargdesc is None else prefillargdesc.get("value") + self.edit.SetValue(prefill) + # TODO add a button to add more ArgEditror instance + # when ordinality is multiple + + def OnArgChanged(self, event): + txt = self.edit.GetValue() + accepts = self.argdesc.get("accepts").split(',') + self.setValidity( + reduce(or_, + map(lambda typename: + models[typename].match(txt) is not None, + accepts), + False) + if accepts and txt else None) + self.ParentObj.RegenSVGLater() + event.Skip() + +class PathEditor(ParamEditor): + def __init__(self, parent, pathdesc): + ParamEditor.__init__(self, parent, pathdesc) + self.ParentObj = parent + self.pathdesc = pathdesc + DropTarget = PathDropTarget(self) + self.edit.SetDropTarget(DropTarget) + self.edit.SetHint(_("Drag'n'drop HMI variable here")) + self.Bind(wx.EVT_TEXT, self.OnPathChanged, self.edit) + + def OnHMITreeDnD(self): + self.ParentObj.GotPathDnDOn(self) + + def SetPath(self, hmitree_node): + self.edit.ChangeValue(hmitree_node.hmi_path()) + self.setValidity( + hmitree_node.nodetype in self.pathdesc.get("accepts").split(",")) + + def OnPathChanged(self, event): + # TODO : find corresponding hmitre node and type to update validity + # Lazy way : hide validity + self.setValidity(None) + self.ParentObj.RegenSVGLater() + event.Skip() + +def KeepDoubleNewLines(txt): + return "\n\n".join(map( + lambda s:re.sub(r'\s+',' ',s), + txt.split("\n\n"))) + +_conf_key = "SVGHMIWidgetLib" +_preview_height = 200 +_preview_margin = 5 +class WidgetLibBrowser(wx.SplitterWindow): + def __init__(self, parent, id=wx.ID_ANY, pos=wx.DefaultPosition, + size=wx.DefaultSize): + + wx.SplitterWindow.__init__(self, parent, + style=wx.SUNKEN_BORDER | wx.SP_3D) + + self.bmp = None + self.msg = None + self.hmitree_nodes = [] + self.selected_SVG = None + + self.Config = wx.ConfigBase.Get() + self.libdir = self.RecallLibDir() + if self.libdir is None: + self.libdir = os.path.join(ScriptDirectory, "widgetlib") + + self.picker_desc_splitter = wx.SplitterWindow(self, style=wx.SUNKEN_BORDER | wx.SP_3D) + + self.picker_panel = wx.Panel(self.picker_desc_splitter) + self.picker_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=2, vgap=0) + self.picker_sizer.AddGrowableCol(0) + self.picker_sizer.AddGrowableRow(1) + + self.widgetpicker = WidgetPicker(self.picker_panel, self.libdir) + self.libbutton = wx.Button(self.picker_panel, -1, _("Select SVG widget library")) + + self.picker_sizer.Add(self.libbutton, flag=wx.GROW) + self.picker_sizer.Add(self.widgetpicker, flag=wx.GROW) + self.picker_sizer.Layout() + self.picker_panel.SetAutoLayout(True) + self.picker_panel.SetSizer(self.picker_sizer) + + self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnWidgetSelection, self.widgetpicker) + self.Bind(wx.EVT_BUTTON, self.OnSelectLibDir, self.libbutton) + + + + self.main_panel = ScrolledPanel(parent=self, + name='MiscellaneousPanel', + style=wx.TAB_TRAVERSAL) + + self.main_sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=0) + self.main_sizer.AddGrowableCol(0) + self.main_sizer.AddGrowableRow(2) + + self.staticmsg = wx.StaticText(self, label = _("Drag selected Widget from here to Inkscape")) + self.preview = wx.Panel(self.main_panel, size=(-1, _preview_height + _preview_margin*2)) + self.signature_sizer = wx.BoxSizer(wx.VERTICAL) + self.args_box = wx.StaticBox(self.main_panel, -1, + _("Widget's arguments"), + style = wx.ALIGN_CENTRE_HORIZONTAL) + self.args_sizer = wx.StaticBoxSizer(self.args_box, wx.VERTICAL) + self.paths_box = wx.StaticBox(self.main_panel, -1, + _("Widget's variables"), + style = wx.ALIGN_CENTRE_HORIZONTAL) + self.paths_sizer = wx.StaticBoxSizer(self.paths_box, wx.VERTICAL) + self.signature_sizer.Add(self.args_sizer, flag=wx.GROW) + self.signature_sizer.AddSpacer(5) + self.signature_sizer.Add(self.paths_sizer, flag=wx.GROW) + self.main_sizer.Add(self.staticmsg, flag=wx.GROW) + self.main_sizer.Add(self.preview, flag=wx.GROW) + self.main_sizer.Add(self.signature_sizer, flag=wx.GROW) + self.main_sizer.Layout() + self.main_panel.SetAutoLayout(True) + self.main_panel.SetSizer(self.main_sizer) + self.main_sizer.Fit(self.main_panel) + self.preview.Bind(wx.EVT_PAINT, self.OnPaint) + self.preview.Bind(wx.EVT_LEFT_DOWN, self.OnLeftDown) + + self.desc = wx.TextCtrl(self.picker_desc_splitter, size=wx.Size(-1, 160), + style=wx.TE_READONLY | wx.TE_MULTILINE) + + self.picker_desc_splitter.SplitHorizontally(self.picker_panel, self.desc, 400) + self.SplitVertically(self.main_panel, self.picker_desc_splitter, 300) + + self.tempf = None + + self.RegenSVGThread = None + self.RegenSVGLock = Lock() + self.RegenSVGTimer = wx.Timer(self, -1) + self.RegenSVGParams = None + self.Bind(wx.EVT_TIMER, + self.RegenSVG, + self.RegenSVGTimer) + + self.args_editors = [] + self.paths_editors = [] + + def SetMessage(self, msg): + self.staticmsg.SetLabel(msg) + self.main_sizer.Layout() + + def ResetSignature(self): + self.args_sizer.Clear() + for editor in self.args_editors: + editor.Destroy() + self.args_editors = [] + + self.paths_sizer.Clear() + for editor in self.paths_editors: + editor.Destroy() + self.paths_editors = [] + + def AddArgToSignature(self, arg, prefillarg): + new_editor = ArgEditor(self, arg, prefillarg) + self.args_editors.append(new_editor) + self.args_sizer.Add(new_editor, flag=wx.GROW) + + def AddPathToSignature(self, path): + new_editor = PathEditor(self, path) + self.paths_editors.append(new_editor) + self.paths_sizer.Add(new_editor, flag=wx.GROW) + + def GotPathDnDOn(self, target_editor): + dndindex = self.paths_editors.index(target_editor) + + for hmitree_node,editor in zip(self.hmitree_nodes, + self.paths_editors[dndindex:]): + editor.SetPath(hmitree_node) + + self.RegenSVGNow() + + def RecallLibDir(self): + conf = self.Config.Read(_conf_key) + if len(conf) == 0: + return None + else: + return DecodeFileSystemPath(conf) + + def RememberLibDir(self, path): + self.Config.Write(_conf_key, + EncodeFileSystemPath(path)) + self.Config.Flush() + + def DrawPreview(self): + """ + Refresh preview panel + """ + # Init preview panel paint device context + dc = wx.PaintDC(self.preview) + dc.Clear() + + if self.bmp: + # Get Preview panel size + sz = self.preview.GetClientSize() + w = self.bmp.GetWidth() + dc.DrawBitmap(self.bmp, (sz.width - w)/2, _preview_margin) + + + + def OnSelectLibDir(self, event): + defaultpath = self.RecallLibDir() + if defaultpath == None: + defaultpath = os.path.expanduser("~") + + dialog = wx.DirDialog(self, _("Choose a widget library"), defaultpath, + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + + if dialog.ShowModal() == wx.ID_OK: + self.libdir = dialog.GetPath() + self.RememberLibDir(self.libdir) + self.widgetpicker.MakeTree(self.libdir) + + dialog.Destroy() + + def OnPaint(self, event): + """ + Called when Preview panel needs to be redrawn + @param event: wx.PaintEvent + """ + self.DrawPreview() + event.Skip() + + def GenThumbnail(self, svgpath, thumbpath): + inkpath = get_inkscape_path() + if inkpath is None: + self.msg = _("Inkscape is not installed.") + return False + # TODO: spawn a thread, to decouple thumbnail gen + status, result, _err_result = ProcessLogger( + None, + '"' + inkpath + '" "' + svgpath + '" -e "' + thumbpath + + '" -D -h ' + str(_preview_height)).spin() + if status != 0: + self.msg = _("Inkscape couldn't generate thumbnail.") + return False + return True + + def OnWidgetSelection(self, event): + """ + Called when tree item is selected + @param event: wx.TreeEvent + """ + item_pydata = self.widgetpicker.GetPyData(event.GetItem()) + if item_pydata is not None: + svgpath = item_pydata + dname = os.path.dirname(svgpath) + fname = os.path.basename(svgpath) + hasher = hashlib.new('md5') + with open(svgpath, 'rb') as afile: + while True: + buf = afile.read(65536) + if len(buf) > 0: + hasher.update(buf) + else: + break + digest = hasher.hexdigest() + thumbfname = os.path.splitext(fname)[0]+"_"+digest+".png" + thumbdir = os.path.join(dname, ".svghmithumbs") + thumbpath = os.path.join(thumbdir, thumbfname) + + have_thumb = os.path.exists(thumbpath) + + try: + if not have_thumb: + if not os.path.exists(thumbdir): + os.mkdir(thumbdir) + have_thumb = self.GenThumbnail(svgpath, thumbpath) + + self.bmp = wx.Bitmap(thumbpath) if have_thumb else None + + self.selected_SVG = svgpath if have_thumb else None + + self.AnalyseWidgetAndUpdateUI(fname) + + self.SetMessage(self.msg) + + except IOError: + self.msg = _("Widget library must be writable") + + self.Refresh() + event.Skip() + + def OnHMITreeNodeSelection(self, hmitree_nodes): + self.hmitree_nodes = hmitree_nodes + + def OnLeftDown(self, evt): + if self.tempf is not None: + filename = self.tempf.name + data = wx.FileDataObject() + data.AddFile(filename) + dropSource = wx.DropSource(self) + dropSource.SetData(data) + dropSource.DoDragDrop(wx.Drag_AllowMove) + + def RegenSVGLater(self, when=1): + self.SetMessage(_("SVG generation pending")) + self.RegenSVGTimer.Start(milliseconds=when*1000, oneShot=True) + + def RegenSVGNow(self): + self.RegenSVGLater(when=0) + + def RegenSVG(self, event): + self.SetMessage(_("Generating SVG...")) + args = [arged.GetValue() for arged in self.args_editors] + while args and not args[-1]: args.pop(-1) + paths = [pathed.GetValue() for pathed in self.paths_editors] + while paths and not paths[-1]: paths.pop(-1) + if self.RegenSVGLock.acquire(True): + self.RegenSVGParams = (args, paths) + if self.RegenSVGThread is None: + self.RegenSVGThread = \ + Thread(target=self.RegenSVGProc, + name="RegenSVGThread").start() + self.RegenSVGLock.release() + event.Skip() + + def RegenSVGProc(self): + self.RegenSVGLock.acquire(True) + + newparams = self.RegenSVGParams + self.RegenSVGParams = None + + while newparams is not None: + self.RegenSVGLock.release() + + res = self.GenDnDSVG(newparams) + + self.RegenSVGLock.acquire(True) + + newparams = self.RegenSVGParams + self.RegenSVGParams = None + + self.RegenSVGThread = None + + self.RegenSVGLock.release() + + wx.CallAfter(self.DoneRegenSVG) + + def DoneRegenSVG(self): + self.SetMessage(self.msg if self.msg else _("SVG ready for drag'n'drop")) + + def AnalyseWidgetAndUpdateUI(self, fname): + self.msg = "" + self.ResetSignature() + + try: + if self.selected_SVG is None: + raise Exception(_("No widget selected")) + + transform = XSLTransform( + os.path.join(ScriptDirectory, "analyse_widget.xslt"),[]) + + svgdom = etree.parse(self.selected_SVG) + + signature = transform.transform(svgdom) + + for entry in transform.get_error_log(): + self.msg += "XSLT: " + entry.message + "\n" + + except Exception as e: + self.msg += str(e) + return + except XSLTApplyError as e: + self.msg += "Widget " + fname + " analysis error: " + e.message + return + + self.msg += "Widget " + fname + ": OK" + + widgets = signature.getroot() + widget = widgets.find("widget") + defs = widget.find("defs") + # Keep double newlines (to mark paragraphs) + widget_desc = widget.find("desc") + self.desc.SetValue( + fname + ":\n\n" + ( + _("No description given") if widget_desc is None else + KeepDoubleNewLines(widget_desc.text) + ) + "\n\n" + + defs.find("type").text + " Widget: "+defs.find("shortdesc").text+"\n\n" + + KeepDoubleNewLines(defs.find("longdesc").text)) + prefillargs = widget.findall("arg") + args = defs.findall("arg") + # extend args description in prefilled args in longer + # (case of variable list of args) + if len(prefillargs) < len(args): + prefillargs += [None]*(len(args)-len(prefillargs)) + if args and len(prefillargs) > len(args): + # TODO: check ordinality of last arg + # TODO: check that only last arg has multiple ordinality + args += [args[-1]]*(len(prefillargs)-len(args)) + self.args_box.Show(len(args)!=0) + for arg, prefillarg in izip(args,prefillargs): + self.AddArgToSignature(arg, prefillarg) + paths = defs.findall("path") + self.paths_box.Show(len(paths)!=0) + for path in paths: + self.AddPathToSignature(path) + + for widget in widgets: + widget_type = widget.get("type") + for path in widget.iterchildren("path"): + path_value = path.get("value") + path_accepts = map( + str.strip, path.get("accepts", '')[1:-1].split(',')) + + self.main_panel.SetupScrolling(scroll_x=False) + + def GetWidgetParams(self, _context): + args,paths = self.GenDnDSVGParams + root = etree.Element("params") + for arg in args: + etree.SubElement(root, "arg", value=arg) + for path in paths: + etree.SubElement(root, "path", value=path) + return root + + + def GenDnDSVG(self, newparams): + self.msg = "" + + self.GenDnDSVGParams = newparams + + if self.tempf is not None: + os.unlink(self.tempf.name) + self.tempf = None + + try: + if self.selected_SVG is None: + raise Exception(_("No widget selected")) + + transform = XSLTransform( + os.path.join(ScriptDirectory, "gen_dnd_widget_svg.xslt"), + [("GetWidgetParams", self.GetWidgetParams)]) + + svgdom = etree.parse(self.selected_SVG) + + result = transform.transform(svgdom) + + for entry in transform.get_error_log(): + self.msg += "XSLT: " + entry.message + "\n" + + self.tempf = NamedTemporaryFile(suffix='.svg', delete=False) + result.write(self.tempf, encoding="utf-8") + self.tempf.close() + + except Exception as e: + self.msg += str(e) + except XSLTApplyError as e: + self.msg += "Widget transform error: " + e.message + + def __del__(self): + if self.tempf is not None: + os.unlink(self.tempf.name) + +class SVGHMI_UI(wx.SplitterWindow): + + def __init__(self, parent, register_for_HMI_tree_updates): + wx.SplitterWindow.__init__(self, parent, + style=wx.SUNKEN_BORDER | wx.SP_3D) + + self.SelectionTree = HMITreeSelector(self) + self.Staging = WidgetLibBrowser(self) + self.SplitVertically(self.SelectionTree, self.Staging, 300) + register_for_HMI_tree_updates(weakref.ref(self)) + + def HMITreeUpdate(self, hmi_tree_root): + self.SelectionTree.MakeTree(hmi_tree_root) + + def OnHMITreeNodeSelection(self, hmitree_nodes): + self.Staging.OnHMITreeNodeSelection(hmitree_nodes)