1+ import os
2+ from collections import defaultdict
13from itertools import chain
4+ import json
25
36import numpy as np
47
5- from AnyQt .QtCore import Qt , QSize , QAbstractTableModel , QModelIndex , QTimer
8+ from AnyQt .QtCore import Qt , QSize , QAbstractTableModel , QModelIndex , QTimer , \
9+ QSettings
610from AnyQt .QtGui import QColor , QFont , QBrush
7- from AnyQt .QtWidgets import QHeaderView , QColorDialog , QTableView , QComboBox
11+ from AnyQt .QtWidgets import QHeaderView , QColorDialog , QTableView , QComboBox , \
12+ QFileDialog , QMessageBox
813
914import Orange
1015from Orange .preprocess .transformation import Identity
11- from Orange .util import color_to_hex
16+ from Orange .util import color_to_hex , hex_to_color
1217from Orange .widgets import widget , settings , gui
1318from Orange .widgets .gui import HorizontalGridDelegate
1419from Orange .widgets .utils import itemmodels , colorpalettes
2126StripRole = next (gui .OrangeUserRole )
2227
2328
29+ class InvalidFileFormat (Exception ):
30+ pass
31+
32+
33+ def _check_dict_str_str (d ):
34+ if not isinstance (d , dict ) or \
35+ not all (isinstance (val , str ) for val in chain (d , d .values ())):
36+ raise InvalidFileFormat
37+
38+
2439class AttrDesc :
2540 """
2641 Describes modifications that will be applied to variable.
@@ -46,6 +61,22 @@ def name(self):
4661 def name (self , name ):
4762 self .new_name = name
4863
64+ def to_dict (self ):
65+ d = {}
66+ if self .new_name is not None :
67+ d ["rename" ] = self .new_name
68+ return d
69+
70+ @classmethod
71+ def from_dict (cls , var , data ):
72+ desc = cls (var )
73+ new_name = data .get ("rename" )
74+ if new_name is not None :
75+ if not isinstance (desc .name , str ):
76+ raise InvalidFileFormat
77+ desc .name = new_name
78+ return desc , []
79+
4980
5081class DiscAttrDesc (AttrDesc ):
5182 """
@@ -96,6 +127,49 @@ def create_variable(self):
96127 new_var .colors = np .asarray (self .colors )
97128 return new_var
98129
130+ def to_dict (self ):
131+ d = super ().to_dict ()
132+ if self .new_values is not None :
133+ d ["renamed_values" ] = \
134+ {k : v
135+ for k , v in zip (self .var .values , self .new_values )
136+ if k != v }
137+ if self .new_colors is not None :
138+ d ["colors" ] = {value : color_to_hex (color )
139+ for value , color in zip (self .values , self .colors )}
140+ return d
141+
142+ @classmethod
143+ def from_dict (cls , var , data ):
144+ obj , warnings = super ().from_dict (var , data )
145+
146+ val_map = data .get ("renamed_values" )
147+ if val_map is not None :
148+ _check_dict_str_str (val_map )
149+ mapped_values = [val_map .get (value , value ) for value in var .values ]
150+ if len (set (mapped_values )) != len (mapped_values ):
151+ warnings .append (
152+ f"{ var .name } : "
153+ "renaming of values ignored due to duplicate names" )
154+ else :
155+ obj .new_values = mapped_values
156+
157+ new_colors = data .get ("colors" )
158+ if new_colors is not None :
159+ _check_dict_str_str (new_colors )
160+ colors = []
161+ for value , def_color in zip (var .values , var .palette ):
162+ if value in new_colors :
163+ try :
164+ color = hex_to_color (new_colors [value ])
165+ except (IndexError , ValueError ) as exc :
166+ raise InvalidFileFormat from exc
167+ colors .append (color )
168+ else :
169+ colors .append (def_color )
170+ obj .new_colors = colors
171+ return obj , warnings
172+
99173
100174class ContAttrDesc (AttrDesc ):
101175 """
@@ -136,6 +210,22 @@ def create_variable(self):
136210 new_var .attributes ["palette" ] = self .palette_name
137211 return new_var
138212
213+ def to_dict (self ):
214+ d = super ().to_dict ()
215+ if self .new_palette_name is not None :
216+ d ["colors" ] = self .palette_name
217+ return d
218+
219+ @classmethod
220+ def from_dict (cls , var , data ):
221+ obj , warnings = super ().from_dict (var , data )
222+ colors = data .get ("colors" )
223+ if colors is not None :
224+ if colors not in colorpalettes .ContinuousPalettes :
225+ raise InvalidFileFormat
226+ obj .palette_name = colors
227+ return obj , warnings
228+
139229
140230class ColorTableModel (QAbstractTableModel ):
141231 """
@@ -454,14 +544,17 @@ class Outputs:
454544 match_values = settings .PerfectDomainContextHandler .MATCH_VALUES_ALL )
455545 disc_descs = settings .ContextSetting ([])
456546 cont_descs = settings .ContextSetting ([])
457- color_settings = settings .Setting (None )
458547 selected_schema_index = settings .Setting (0 )
459548 auto_apply = settings .Setting (True )
460549
461550 settings_version = 2
462551
463552 want_main_area = False
464553
554+ FileFilters = [
555+ "Settings for individual variables (*.vdefs)" ,
556+ "General color encoding for values (*.colors)" ]
557+
465558 def __init__ (self ):
466559 super ().__init__ ()
467560 self .data = None
@@ -481,9 +574,13 @@ def __init__(self):
481574
482575 box = gui .auto_apply (self .controlArea , self , "auto_apply" )
483576 box .button .setFixedWidth (180 )
577+ save = gui .button (None , self , "Save" , callback = self .save )
578+ load = gui .button (None , self , "Load" , callback = self .load )
484579 reset = gui .button (None , self , "Reset" , callback = self .reset )
485- box .layout ().insertWidget (0 , reset )
486- box .layout ().insertStretch (1 )
580+ box .layout ().insertWidget (0 , save )
581+ box .layout ().insertWidget (0 , load )
582+ box .layout ().insertWidget (2 , reset )
583+ box .layout ().insertStretch (3 )
487584
488585 self .info .set_input_summary (self .info .NoInput )
489586 self .info .set_output_summary (self .info .NoOutput )
@@ -524,6 +621,146 @@ def reset(self):
524621 self .cont_model .reset ()
525622 self .commit ()
526623
624+ def save (self ):
625+ fname , ffilter = QFileDialog .getSaveFileName (
626+ self , "File name" , self ._start_dir (), ";;" .join (self .FileFilters ))
627+ if not fname :
628+ return
629+ QSettings ().setValue ("colorwidget/last-location" ,
630+ os .path .split (fname )[0 ])
631+ if ffilter == self .FileFilters [0 ]:
632+ self ._save_var_defs (fname )
633+ else :
634+ self ._save_value_colors (fname )
635+
636+ def _save_var_defs (self , fname ):
637+ json .dump (
638+ {vartype : {
639+ var .name : var_data
640+ for var , var_data in (
641+ (desc .var , desc .to_dict ()) for desc in repo )
642+ if var_data }
643+ for vartype , repo in (("categorical" , self .disc_descs ),
644+ ("numeric" , self .cont_descs ))
645+ },
646+ open (fname , "w" ),
647+ indent = 4 )
648+
649+ def _save_value_colors (self , fname ):
650+ color_map = defaultdict (set )
651+ for desc in self .disc_descs :
652+ if desc .new_colors is None :
653+ continue
654+ for value , old_color , new_color in zip (
655+ desc .var .values , desc .var .palette .palette , desc .new_colors ):
656+ old_hex , new_hex = map (color_to_hex , (old_color , new_color ))
657+ if old_hex != new_hex :
658+ color_map [value ].add (new_hex )
659+ js = {value : colors .pop ()
660+ for value , colors in color_map .items ()
661+ if len (colors ) == 1 }
662+ json .dump (js , open (fname , "w" ), indent = 4 )
663+
664+ def load (self ):
665+ try :
666+ fname , ffilter = QFileDialog .getOpenFileName (
667+ self , "File name" , self ._start_dir (),
668+ ";;" .join (self .FileFilters ))
669+ if not fname :
670+ return
671+ try :
672+ js = json .load (open (fname )) #: dict
673+ except IOError :
674+ QMessageBox .critical (self , "File error" ,
675+ "File cannot be opened." )
676+ return
677+ except json .JSONDecodeError as exc :
678+ raise InvalidFileFormat from exc
679+ if ffilter == self .FileFilters [0 ]:
680+ self ._parse_var_defs (js )
681+ else :
682+ self ._parse_value_colors (js )
683+ except InvalidFileFormat :
684+ QMessageBox .critical (self , "File error" , "Invalid file format." )
685+ else :
686+ self .unconditional_commit ()
687+
688+ def _parse_var_defs (self , js ):
689+ if not isinstance (js , dict ):
690+ raise InvalidFileFormat
691+ renames = {
692+ var_name : desc ["rename" ]
693+ for repo in js .values () for var_name , desc in repo .items ()
694+ if "rename" in desc
695+ }
696+ if not all (isinstance (val , str )
697+ for val in chain (renames , renames .values ())):
698+ raise InvalidFileFormat
699+ renamed_vars = {
700+ renames .get (desc .var .name , desc .var .name )
701+ for desc in chain (self .disc_descs , self .cont_descs )
702+ }
703+ if len (renamed_vars ) != len (self .disc_descs ) + len (self .cont_descs ):
704+ QMessageBox .warning (
705+ self ,
706+ "Duplicated variable names" ,
707+ "Variables will not be renamed due to duplicated names." )
708+ for repo in js .values ():
709+ for desc in repo .values ():
710+ desc .pop ("rename" , None )
711+
712+ # First, construct all descriptions; assign later, after we know
713+ # there won't be exceptions due to invalid file format
714+ both_descs = []
715+ warnings = []
716+ for old_desc , repo , desc_type in (
717+ (self .disc_descs , "categorical" , DiscAttrDesc ),
718+ (self .cont_descs , "numeric" , ContAttrDesc )):
719+ var_by_name = {desc .var .name : desc .var for desc in old_desc }
720+ new_descs = {}
721+ for var_name , var_data in js [repo ].items ():
722+ var = var_by_name .get (var_name )
723+ if var is None :
724+ continue
725+ # This can throw InvalidFileFormat
726+ new_descs [var_name ], warn = desc_type .from_dict (var , var_data )
727+ warnings += warn
728+ both_descs .append (new_descs )
729+
730+ self .disc_descs = [both_descs [0 ].get (desc .var .name , desc )
731+ for desc in self .disc_descs ]
732+ self .cont_descs = [both_descs [0 ].get (desc .var .name , desc )
733+ for desc in self .cont_descs ]
734+ if warnings :
735+ QMessageBox .warning (
736+ self , "Invalid definitions" , "\n " .join (warnings ))
737+
738+ self .disc_model .set_data (self .disc_descs )
739+ self .cont_model .set_data (self .cont_descs )
740+ self .unconditional_commit ()
741+
742+ def _parse_value_colors (self , js ):
743+ if not isinstance (js , dict ) or \
744+ any (not isinstance (obj , str ) for obj in chain (js , js .values ())):
745+ raise InvalidFileFormat
746+ try :
747+ js = {k : hex_to_color (v ) for k , v in js .items ()}
748+ except (ValueError , IndexError ) as exc :
749+ raise InvalidFileFormat from exc
750+
751+ for desc in self .disc_descs :
752+ for i , value in enumerate (desc .var .values ):
753+ if value in js :
754+ desc .set_color (i , js [value ])
755+
756+ self .disc_model .set_data (self .disc_descs )
757+ self .unconditional_commit ()
758+
759+ def _start_dir (self ):
760+ return self .workflowEnv ().get ("basedir" ) \
761+ or QSettings ().value ("colorwidget/last-location" ) \
762+ or os .path .expanduser (f"~{ os .sep } " )
763+
527764 def commit (self ):
528765 def make (variables ):
529766 new_vars = []
0 commit comments