1 #!/usr/bin/env python |
1 #!/usr/bin/env python |
2 # -*- coding: utf-8 -*- |
2 # -*- coding: utf-8 -*- |
3 |
3 |
4 # This file is part of Beremiz |
4 # This file is part of Beremiz |
5 # Copyright (C) 2019: Edouard TISSERANT |
5 # Copyright (C) 2021: Edouard TISSERANT |
6 # |
6 # |
7 # See COPYING file for copyrights details. |
7 # See COPYING file for copyrights details. |
8 |
8 |
9 from __future__ import absolute_import |
9 from __future__ import absolute_import |
10 import os |
10 import os |
11 import shutil |
11 import shutil |
12 from itertools import izip, imap |
|
13 from pprint import pformat |
|
14 import hashlib |
12 import hashlib |
15 import weakref |
|
16 import shlex |
13 import shlex |
17 import time |
14 import time |
18 |
15 |
19 import wx |
16 import wx |
20 |
17 |
21 from lxml import etree |
18 from lxml import etree |
22 from lxml.etree import XSLTApplyError |
19 from lxml.etree import XSLTApplyError |
23 |
20 |
24 from IDEFrame import EncodeFileSystemPath, DecodeFileSystemPath |
|
25 import util.paths as paths |
21 import util.paths as paths |
26 from POULibrary import POULibrary |
22 from POULibrary import POULibrary |
27 from docutil import open_svg, get_inkscape_path |
23 from docutil import open_svg, get_inkscape_path |
28 |
24 |
29 from util.ProcessLogger import ProcessLogger |
25 from util.ProcessLogger import ProcessLogger |
30 from runtime.typemapping import DebugTypesSize |
26 from runtime.typemapping import DebugTypesSize |
31 import targets |
27 import targets |
32 from editors.ConfTreeNodeEditor import ConfTreeNodeEditor |
28 from editors.ConfTreeNodeEditor import ConfTreeNodeEditor |
33 from XSLTransform import XSLTransform |
29 from XSLTransform import XSLTransform |
34 from svghmi.i18n import EtreeToMessages, SaveCatalog, ReadTranslations, MatchTranslations, TranslationToEtree, open_pofile |
30 from svghmi.i18n import EtreeToMessages, SaveCatalog, ReadTranslations,\ |
35 |
31 MatchTranslations, TranslationToEtree, open_pofile |
36 HMI_TYPES_DESC = { |
32 from svghmi.hmi_tree import HMI_TYPES, HMITreeNode, SPECIAL_NODES |
37 "HMI_NODE":{}, |
33 from svghmi.ui import SVGHMI_UI |
38 "HMI_STRING":{}, |
|
39 "HMI_INT":{}, |
|
40 "HMI_BOOL":{}, |
|
41 "HMI_REAL":{} |
|
42 } |
|
43 |
|
44 HMI_TYPES = HMI_TYPES_DESC.keys() |
|
45 |
34 |
46 |
35 |
47 ScriptDirectory = paths.AbsDir(__file__) |
36 ScriptDirectory = paths.AbsDir(__file__) |
48 |
37 |
49 class HMITreeNode(object): |
|
50 def __init__(self, path, name, nodetype, iectype = None, vartype = None, cpath = None, hmiclass = None): |
|
51 self.path = path |
|
52 self.name = name |
|
53 self.nodetype = nodetype |
|
54 self.hmiclass = hmiclass |
|
55 |
|
56 if iectype is not None: |
|
57 self.iectype = iectype |
|
58 self.vartype = vartype |
|
59 self.cpath = cpath |
|
60 |
|
61 if nodetype in ["HMI_NODE"]: |
|
62 self.children = [] |
|
63 |
|
64 def pprint(self, indent = 0): |
|
65 res = ">"*indent + pformat(self.__dict__, indent = indent, depth = 1) + "\n" |
|
66 if hasattr(self, "children"): |
|
67 res += "\n".join([child.pprint(indent = indent + 1) |
|
68 for child in self.children]) |
|
69 res += "\n" |
|
70 |
|
71 return res |
|
72 |
|
73 def place_node(self, node): |
|
74 best_child = None |
|
75 known_best_match = 0 |
|
76 potential_siblings = {} |
|
77 for child in self.children: |
|
78 if child.path is not None: |
|
79 in_common = 0 |
|
80 for child_path_item, node_path_item in izip(child.path, node.path): |
|
81 if child_path_item == node_path_item: |
|
82 in_common +=1 |
|
83 else: |
|
84 break |
|
85 # Match can only be HMI_NODE, and the whole path of node |
|
86 # must match candidate node (except for name part) |
|
87 # since candidate would become child of that node |
|
88 if in_common > known_best_match and \ |
|
89 child.nodetype == "HMI_NODE" and \ |
|
90 in_common == len(child.path) - 1: |
|
91 known_best_match = in_common |
|
92 best_child = child |
|
93 else: |
|
94 potential_siblings[child.path[ |
|
95 -2 if child.nodetype == "HMI_NODE" else -1]] = child |
|
96 if best_child is not None: |
|
97 if node.nodetype == "HMI_NODE" and best_child.path[:-1] == node.path[:-1]: |
|
98 return "Duplicate_HMI_NODE", best_child |
|
99 return best_child.place_node(node) |
|
100 else: |
|
101 candidate_name = node.path[-2 if node.nodetype == "HMI_NODE" else -1] |
|
102 if candidate_name in potential_siblings: |
|
103 return "Non_Unique", potential_siblings[candidate_name] |
|
104 |
|
105 if node.nodetype == "HMI_NODE" and len(self.children) > 0: |
|
106 prev = self.children[-1] |
|
107 if prev.path[:-1] == node.path[:-1]: |
|
108 return "Late_HMI_NODE",prev |
|
109 |
|
110 self.children.append(node) |
|
111 return None |
|
112 |
|
113 def etree(self, add_hash=False): |
|
114 |
|
115 attribs = dict(name=self.name) |
|
116 if self.path is not None: |
|
117 attribs["path"] = ".".join(self.path) |
|
118 |
|
119 if self.hmiclass is not None: |
|
120 attribs["class"] = self.hmiclass |
|
121 |
|
122 if add_hash: |
|
123 attribs["hash"] = ",".join(map(str,self.hash())) |
|
124 |
|
125 res = etree.Element(self.nodetype, **attribs) |
|
126 |
|
127 if hasattr(self, "children"): |
|
128 for child_etree in imap(lambda c:c.etree(), self.children): |
|
129 res.append(child_etree) |
|
130 |
|
131 return res |
|
132 |
|
133 @classmethod |
|
134 def from_etree(cls, enode): |
|
135 """ |
|
136 alternative constructor, restoring HMI Tree from XML backup |
|
137 note: all C-related information is gone, |
|
138 this restore is only for tree display and widget picking |
|
139 """ |
|
140 nodetype = enode.tag |
|
141 attributes = enode.attrib |
|
142 name = attributes["name"] |
|
143 path = attributes["path"].split('.') if "path" in attributes else None |
|
144 hmiclass = attributes.get("class", None) |
|
145 # hash is computed on demand |
|
146 node = cls(path, name, nodetype, hmiclass=hmiclass) |
|
147 for child in enode.iterchildren(): |
|
148 node.children.append(cls.from_etree(child)) |
|
149 return node |
|
150 |
|
151 def traverse(self): |
|
152 yield self |
|
153 if hasattr(self, "children"): |
|
154 for c in self.children: |
|
155 for yoodl in c.traverse(): |
|
156 yield yoodl |
|
157 |
|
158 |
|
159 def hash(self): |
|
160 """ Produce a hash, any change in HMI tree structure change that hash """ |
|
161 s = hashlib.new('md5') |
|
162 self._hash(s) |
|
163 # limit size to HMI_HASH_SIZE as in svghmi.c |
|
164 return map(ord,s.digest())[:8] |
|
165 |
|
166 def _hash(self, s): |
|
167 s.update(str((self.name,self.nodetype))) |
|
168 if hasattr(self, "children"): |
|
169 for c in self.children: |
|
170 c._hash(s) |
|
171 |
38 |
172 # module scope for HMITree root |
39 # module scope for HMITree root |
173 # so that CTN can use HMITree deduced in Library |
40 # so that CTN can use HMITree deduced in Library |
174 # note: this only works because library's Generate_C is |
41 # note: this only works because library's Generate_C is |
175 # systematicaly invoked before CTN's CTNGenerate_C |
42 # systematicaly invoked before CTN's CTNGenerate_C |
176 |
43 |
177 hmi_tree_root = None |
44 hmi_tree_root = None |
178 |
45 |
179 on_hmitree_update = None |
46 on_hmitree_update = None |
180 |
|
181 SPECIAL_NODES = [("HMI_ROOT", "HMI_NODE"), |
|
182 ("heartbeat", "HMI_INT")] |
|
183 # ("current_page", "HMI_STRING")]) |
|
184 |
47 |
185 class SVGHMILibrary(POULibrary): |
48 class SVGHMILibrary(POULibrary): |
186 def GetLibraryPath(self): |
49 def GetLibraryPath(self): |
187 return paths.AbsNeighbourFile(__file__, "pous.xml") |
50 return paths.AbsNeighbourFile(__file__, "pous.xml") |
188 |
51 |
372 # ^ |
235 # ^ |
373 # note the double zero after "runtime_", |
236 # note the double zero after "runtime_", |
374 # to ensure placement before other CTN generated code in execution order |
237 # to ensure placement before other CTN generated code in execution order |
375 |
238 |
376 |
239 |
377 def SVGHMIEditorUpdater(ref): |
240 def Register_SVGHMI_UI_for_HMI_tree_updates(ref): |
378 def SVGHMIEditorUpdate(): |
241 global on_hmitree_update |
379 o = ref() |
242 def HMITreeUpdate(_hmi_tree_root): |
380 if o is not None: |
243 obj = ref() |
381 wx.CallAfter(o.MakeTree) |
244 if obj is not None: |
382 return SVGHMIEditorUpdate |
245 obj.HMITreeUpdate(_hmi_tree_root) |
383 |
246 |
384 class HMITreeSelector(wx.TreeCtrl): |
247 on_hmitree_update = HMITreeUpdate |
385 def __init__(self, parent): |
|
386 global on_hmitree_update |
|
387 wx.TreeCtrl.__init__(self, parent, style=( |
|
388 wx.TR_MULTIPLE | |
|
389 wx.TR_HAS_BUTTONS | |
|
390 wx.SUNKEN_BORDER | |
|
391 wx.TR_LINES_AT_ROOT)) |
|
392 |
|
393 on_hmitree_update = SVGHMIEditorUpdater(weakref.ref(self)) |
|
394 self.MakeTree() |
|
395 |
|
396 def _recurseTree(self, current_hmitree_root, current_tc_root): |
|
397 for c in current_hmitree_root.children: |
|
398 if hasattr(c, "children"): |
|
399 display_name = ('{} (class={})'.format(c.name, c.hmiclass)) \ |
|
400 if c.hmiclass is not None else c.name |
|
401 tc_child = self.AppendItem(current_tc_root, display_name) |
|
402 self.SetPyData(tc_child, None) # TODO |
|
403 |
|
404 self._recurseTree(c,tc_child) |
|
405 else: |
|
406 display_name = '{} {}'.format(c.nodetype[4:], c.name) |
|
407 tc_child = self.AppendItem(current_tc_root, display_name) |
|
408 self.SetPyData(tc_child, None) # TODO |
|
409 |
|
410 def MakeTree(self): |
|
411 global hmi_tree_root |
|
412 |
|
413 self.Freeze() |
|
414 |
|
415 self.root = None |
|
416 self.DeleteAllItems() |
|
417 |
|
418 root_display_name = _("Please build to see HMI Tree") \ |
|
419 if hmi_tree_root is None else "HMI" |
|
420 self.root = self.AddRoot(root_display_name) |
|
421 self.SetPyData(self.root, None) |
|
422 |
|
423 if hmi_tree_root is not None: |
|
424 self._recurseTree(hmi_tree_root, self.root) |
|
425 self.Expand(self.root) |
|
426 |
|
427 self.Thaw() |
|
428 |
|
429 class WidgetPicker(wx.TreeCtrl): |
|
430 def __init__(self, parent, initialdir=None): |
|
431 wx.TreeCtrl.__init__(self, parent, style=( |
|
432 wx.TR_MULTIPLE | |
|
433 wx.TR_HAS_BUTTONS | |
|
434 wx.SUNKEN_BORDER | |
|
435 wx.TR_LINES_AT_ROOT)) |
|
436 |
|
437 self.MakeTree(initialdir) |
|
438 |
|
439 def _recurseTree(self, current_dir, current_tc_root, dirlist): |
|
440 """ |
|
441 recurse through subdirectories, but creates tree nodes |
|
442 only when (sub)directory conbtains .svg file |
|
443 """ |
|
444 res = [] |
|
445 for f in sorted(os.listdir(current_dir)): |
|
446 p = os.path.join(current_dir,f) |
|
447 if os.path.isdir(p): |
|
448 |
|
449 r = self._recurseTree(p, current_tc_root, dirlist + [f]) |
|
450 if len(r) > 0 : |
|
451 res = r |
|
452 dirlist = [] |
|
453 current_tc_root = res.pop() |
|
454 |
|
455 elif os.path.splitext(f)[1].upper() == ".SVG": |
|
456 if len(dirlist) > 0 : |
|
457 res = [] |
|
458 for d in dirlist: |
|
459 current_tc_root = self.AppendItem(current_tc_root, d) |
|
460 res.append(current_tc_root) |
|
461 self.SetPyData(current_tc_root, None) |
|
462 dirlist = [] |
|
463 res.pop() |
|
464 tc_child = self.AppendItem(current_tc_root, f) |
|
465 self.SetPyData(tc_child, p) |
|
466 return res |
|
467 |
|
468 def MakeTree(self, lib_dir = None): |
|
469 global hmi_tree_root |
|
470 |
|
471 self.Freeze() |
|
472 |
|
473 self.root = None |
|
474 self.DeleteAllItems() |
|
475 |
|
476 root_display_name = _("Please select widget library directory") \ |
|
477 if lib_dir is None else os.path.basename(lib_dir) |
|
478 self.root = self.AddRoot(root_display_name) |
|
479 self.SetPyData(self.root, None) |
|
480 |
|
481 if lib_dir is not None: |
|
482 self._recurseTree(lib_dir, self.root, []) |
|
483 self.Expand(self.root) |
|
484 |
|
485 self.Thaw() |
|
486 |
|
487 _conf_key = "SVGHMIWidgetLib" |
|
488 _preview_height = 200 |
|
489 class WidgetLibBrowser(wx.Panel): |
|
490 def __init__(self, parent, id=wx.ID_ANY, pos=wx.DefaultPosition, |
|
491 size=wx.DefaultSize): |
|
492 |
|
493 wx.Panel.__init__(self, parent, id, pos, size) |
|
494 |
|
495 self.bmp = None |
|
496 self.msg = None |
|
497 self.hmitree_node = None |
|
498 self.selected_SVG = None |
|
499 |
|
500 self.Config = wx.ConfigBase.Get() |
|
501 self.libdir = self.RecallLibDir() |
|
502 |
|
503 sizer = wx.FlexGridSizer(cols=1, hgap=0, rows=3, vgap=0) |
|
504 sizer.AddGrowableCol(0) |
|
505 sizer.AddGrowableRow(1) |
|
506 self.libbutton = wx.Button(self, -1, _("Select SVG widget library")) |
|
507 self.widgetpicker = WidgetPicker(self, self.libdir) |
|
508 self.preview = wx.Panel(self, size=(-1, _preview_height + 10)) #, style=wx.SIMPLE_BORDER) |
|
509 #self.preview.SetBackgroundColour(wx.WHITE) |
|
510 sizer.AddWindow(self.libbutton, flag=wx.GROW) |
|
511 sizer.AddWindow(self.widgetpicker, flag=wx.GROW) |
|
512 sizer.AddWindow(self.preview, flag=wx.GROW) |
|
513 sizer.Layout() |
|
514 self.SetAutoLayout(True) |
|
515 self.SetSizer(sizer) |
|
516 sizer.Fit(self) |
|
517 self.Bind(wx.EVT_BUTTON, self.OnSelectLibDir, self.libbutton) |
|
518 self.preview.Bind(wx.EVT_PAINT, self.OnPaint) |
|
519 |
|
520 self.Bind(wx.EVT_TREE_SEL_CHANGED, self.OnWidgetSelection, self.widgetpicker) |
|
521 |
|
522 self.msg = _("Drag selected Widget from here to Inkscape") |
|
523 |
|
524 def RecallLibDir(self): |
|
525 conf = self.Config.Read(_conf_key) |
|
526 if len(conf) == 0: |
|
527 return None |
|
528 else: |
|
529 return DecodeFileSystemPath(conf) |
|
530 |
|
531 def RememberLibDir(self, path): |
|
532 self.Config.Write(_conf_key, |
|
533 EncodeFileSystemPath(path)) |
|
534 self.Config.Flush() |
|
535 |
|
536 def DrawPreview(self): |
|
537 """ |
|
538 Refresh preview panel |
|
539 """ |
|
540 # Init preview panel paint device context |
|
541 dc = wx.PaintDC(self.preview) |
|
542 dc.Clear() |
|
543 |
|
544 if self.bmp: |
|
545 # Get Preview panel size |
|
546 sz = self.preview.GetClientSize() |
|
547 w = self.bmp.GetWidth() |
|
548 dc.DrawBitmap(self.bmp, (sz.width - w)/2, 5) |
|
549 |
|
550 if self.msg: |
|
551 dc.SetFont(self.GetFont()) |
|
552 dc.DrawText(self.msg, 25,25) |
|
553 |
|
554 |
|
555 def OnSelectLibDir(self, event): |
|
556 defaultpath = self.RecallLibDir() |
|
557 if defaultpath == None: |
|
558 defaultpath = os.path.expanduser("~") |
|
559 |
|
560 dialog = wx.DirDialog(self, _("Choose a widget library"), defaultpath, |
|
561 style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) |
|
562 |
|
563 if dialog.ShowModal() == wx.ID_OK: |
|
564 self.libdir = dialog.GetPath() |
|
565 self.RememberLibDir(self.libdir) |
|
566 self.widgetpicker.MakeTree(self.libdir) |
|
567 |
|
568 dialog.Destroy() |
|
569 |
|
570 def OnPaint(self, event): |
|
571 """ |
|
572 Called when Preview panel needs to be redrawn |
|
573 @param event: wx.PaintEvent |
|
574 """ |
|
575 self.DrawPreview() |
|
576 event.Skip() |
|
577 |
|
578 def GenThumbnail(self, svgpath, thumbpath): |
|
579 inkpath = get_inkscape_path() |
|
580 if inkpath is None: |
|
581 self.msg = _("Inkscape is not installed.") |
|
582 return False |
|
583 # TODO: spawn a thread, to decouple thumbnail gen |
|
584 status, result, _err_result = ProcessLogger( |
|
585 None, |
|
586 '"' + inkpath + '" "' + svgpath + '" -e "' + thumbpath + |
|
587 '" -D -h ' + str(_preview_height)).spin() |
|
588 if status != 0: |
|
589 self.msg = _("Inkscape couldn't generate thumbnail.") |
|
590 return False |
|
591 return True |
|
592 |
|
593 def OnWidgetSelection(self, event): |
|
594 """ |
|
595 Called when tree item is selected |
|
596 @param event: wx.TreeEvent |
|
597 """ |
|
598 item_pydata = self.widgetpicker.GetPyData(event.GetItem()) |
|
599 if item_pydata is not None: |
|
600 svgpath = item_pydata |
|
601 dname = os.path.dirname(svgpath) |
|
602 fname = os.path.basename(svgpath) |
|
603 hasher = hashlib.new('md5') |
|
604 with open(svgpath, 'rb') as afile: |
|
605 while True: |
|
606 buf = afile.read(65536) |
|
607 if len(buf) > 0: |
|
608 hasher.update(buf) |
|
609 else: |
|
610 break |
|
611 digest = hasher.hexdigest() |
|
612 thumbfname = os.path.splitext(fname)[0]+"_"+digest+".png" |
|
613 thumbdir = os.path.join(dname, ".svghmithumbs") |
|
614 thumbpath = os.path.join(thumbdir, thumbfname) |
|
615 |
|
616 self.msg = None |
|
617 have_thumb = os.path.exists(thumbpath) |
|
618 |
|
619 if not have_thumb: |
|
620 try: |
|
621 if not os.path.exists(thumbdir): |
|
622 os.mkdir(thumbdir) |
|
623 except IOError: |
|
624 self.msg = _("Widget library must be writable") |
|
625 else: |
|
626 have_thumb = self.GenThumbnail(svgpath, thumbpath) |
|
627 |
|
628 self.bmp = wx.Bitmap(thumbpath) if have_thumb else None |
|
629 |
|
630 self.selected_SVG = svgpath if have_thumb else None |
|
631 self.ValidateWidget() |
|
632 |
|
633 self.Refresh() |
|
634 event.Skip() |
|
635 |
|
636 def OnHMITreeNodeSelection(self, hmitree_node): |
|
637 self.hmitree_node = hmitree_node |
|
638 self.ValidateWidget() |
|
639 self.Refresh() |
|
640 |
|
641 def ValidateWidget(self): |
|
642 if self.selected_SVG is not None: |
|
643 if self.hmitree_node is not None: |
|
644 pass |
|
645 # XXX TODO: |
|
646 # - check SVG is valid for selected HMI tree item |
|
647 # - prepare for D'n'D |
|
648 |
|
649 |
|
650 class HMITreeView(wx.SplitterWindow): |
|
651 |
|
652 def __init__(self, parent): |
|
653 wx.SplitterWindow.__init__(self, parent, |
|
654 style=wx.SUNKEN_BORDER | wx.SP_3D) |
|
655 |
|
656 self.SelectionTree = HMITreeSelector(self) |
|
657 self.Staging = WidgetLibBrowser(self) |
|
658 self.SplitVertically(self.SelectionTree, self.Staging, 300) |
|
659 |
248 |
660 |
249 |
661 class SVGHMIEditor(ConfTreeNodeEditor): |
250 class SVGHMIEditor(ConfTreeNodeEditor): |
662 CONFNODEEDITOR_TABS = [ |
251 CONFNODEEDITOR_TABS = [ |
663 (_("HMI Tree"), "CreateHMITreeView")] |
252 (_("HMI Tree"), "CreateSVGHMI_UI")] |
664 |
253 |
665 def CreateHMITreeView(self, parent): |
254 def CreateSVGHMI_UI(self, parent): |
666 global hmi_tree_root |
255 global hmi_tree_root |
667 |
256 |
668 if hmi_tree_root is None: |
257 if hmi_tree_root is None: |
669 buildpath = self.Controler.GetCTRoot()._getBuildPath() |
258 buildpath = self.Controler.GetCTRoot()._getBuildPath() |
670 hmitree_backup_path = os.path.join(buildpath, "hmitree.xml") |
259 hmitree_backup_path = os.path.join(buildpath, "hmitree.xml") |
671 if os.path.exists(hmitree_backup_path): |
260 if os.path.exists(hmitree_backup_path): |
672 hmitree_backup_file = open(hmitree_backup_path, 'rb') |
261 hmitree_backup_file = open(hmitree_backup_path, 'rb') |
673 hmi_tree_root = HMITreeNode.from_etree(etree.parse(hmitree_backup_file).getroot()) |
262 hmi_tree_root = HMITreeNode.from_etree(etree.parse(hmitree_backup_file).getroot()) |
674 |
263 |
675 |
264 return SVGHMI_UI(parent, Register_SVGHMI_UI_for_HMI_tree_updates) |
676 #self.HMITreeView = HMITreeView(self) |
|
677 #return HMITreeSelector(parent) |
|
678 return HMITreeView(parent) |
|
679 |
265 |
680 class SVGHMI(object): |
266 class SVGHMI(object): |
681 XSD = """<?xml version="1.0" encoding="utf-8" ?> |
267 XSD = """<?xml version="1.0" encoding="utf-8" ?> |
682 <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> |
268 <xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"> |
683 <xsd:element name="SVGHMI"> |
269 <xsd:element name="SVGHMI"> |