1''' Lib for Plugin
2Authors:
3    Andrey Kvichansky    (kvichans on github.com)
4Version:
5    '1.1.3 2021-03-08'
6Content
7    log                 Logger with timing
8    get_translation     i18n
9    dlg_wrapper         Wrapper for dlg_custom: pack/unpack values, h-align controls
10ToDo: (see end of file)
11'''
12
13import  sys, os, gettext, logging, inspect, time, collections, json, re, subprocess
14from    time        import perf_counter
15import  cudatext        as app
16import  cudax_lib       as apx
17
18pass;                           # Logging
19pass;                           from pprint import pformat
20
21odict       = collections.OrderedDict
22GAP         = 5
23c13,c10,c9  = chr(13),chr(10),chr(9)
24REDUCTS = {'lb'     :'label'
25        ,  'ln-lb'  :'linklabel'
26        ,  'ed'     :'edit'
27        ,  'sp-ed'  :'spinedit'
28        ,  'me'     :'memo'
29        ,  'bt'     :'button'
30        ,  'rd'     :'radio'
31        ,  'ch'     :'check'
32        ,  'ch-bt'  :'checkbutton'
33        ,  'ch-gp'  :'checkgroup'
34        ,  'rd-gp'  :'radiogroup'
35        ,  'cb'     :'combo'
36        ,  'cb-ro'  :'combo_ro'
37        ,  'lbx'    :'listbox'
38        ,  'ch-lbx' :'checklistbox'
39        ,  'lvw'    :'listview'
40        ,  'ch-lvw' :'checklistview'
41        ,  'tabs'   :'tabs'
42        ,  'clr'    :'colorpanel'
43        }
44
45def f(s, *args, **kwargs):return s.format(*args, **kwargs)
46def to01(bool_val):return '1' if bool_val else '0'
47
48def log(msg='', *args, **kwargs):
49    if args or kwargs:
50        msg = msg.format(*args, **kwargs)
51    if Tr.tr is None:
52        Tr.tr=Tr()
53    return Tr.tr.log(msg)
54
55class Tr :
56    tr=None
57    """ Трассировщик.
58        Основной (единственный) метод: log(строка) - выводит указанную строку в лог.
59        Управляется через команды в строках для вывода.
60        Команды:
61            >>  Увеличить сдвиг при выводе будущих строк (пока жив возвращенный объект)
62            (:) Начать замер нового вложенного периода, закончить когда умрет возвращенный объект
63            (== Начать замер нового вложенного периода
64            ==> Вывести длительность последнего периода
65            ==) Вывести длительность последнего периода и закончить его замер
66            =}} Отменить все замеры
67        Вызов log с командой >> (увеличить сдвиг) возвращает объект,
68            который при уничтожении уменьшит сдвиг
69        """
70    sec_digs        = 2                     # Точность отображения секунд, кол-во дробных знаков
71    se_fmt          = ''
72    mise_fmt        = ''
73    homise_fmt      = ''
74    def __init__(self, log_to_file=None) :
75        # Поля объекта
76        self.gap    = ''                # Отступ
77        self.tm     = perf_counter()    # Отметка времени о запуске
78        self.stms   = []                # Отметки времени о начале замера спец.периода
79
80        if log_to_file:
81            logging.basicConfig( filename=log_to_file
82                                ,filemode='w'
83                                ,level=logging.DEBUG
84                                ,format='%(message)s'
85                                ,datefmt='%H:%M:%S'
86                                ,style='%')
87        else: # to stdout
88            logging.basicConfig( stream=sys.stdout
89                                ,level=logging.DEBUG
90                                ,format='%(message)s'
91                                ,datefmt='%H:%M:%S'
92                                ,style='%')
93        # Tr()
94    def __del__(self):
95        logging.shutdown()
96
97    class TrLiver :
98        cnt = 0
99        """ Автоматически сокращает gap при уничножении
100            Показывает время своей жизни"""
101        def __init__(self, tr, ops) :
102            # Поля объекта
103            self.tr = tr
104            self.ops= ops
105            self.tm = 0
106            self.nm = Tr.TrLiver.cnt
107            if '(:)' in self.ops :
108                # Начать замер нового интервала
109                self.tm = perf_counter()
110        def log(self, msg='') :
111            if '(:)' in self.ops :
112                msg = '{}(:)=[{}]{}'.format( self.nm, Tr.format_tm( perf_counter() - self.tm ), msg )
113                logging.debug( self.tr.format_msg(msg, ops='') )
114        def __del__(self) :
115            #pass;                  logging.debug('in del')
116            if '(:)' in self.ops :
117                msg = '{}(:)=[{}]'.format( self.nm, Tr.format_tm( perf_counter() - self.tm ) )
118                logging.debug( self.tr.format_msg(msg, ops='') )
119            if '>>' in self.ops :
120                self.tr.gap = self.tr.gap[:-1]
121
122    def log(self, msg='') :
123        if '(:)' in msg :
124            Tr.TrLiver.cnt += 1
125            msg     = msg.replace( '(:)', '{}(:)'.format(Tr.TrLiver.cnt) )
126        logging.debug( self.format_msg(msg) )
127        if '>>' in msg :
128            self.gap = self.gap + c9
129            # Создаем объект, который при разрушении сократит gap
130        if '>>' in msg or '(:)' in msg:
131            return Tr.TrLiver(self,('>>' if '>>' in msg else '')+('(:)' if '(:)' in msg else ''))
132            # return Tr.TrLiver(self,iif('>>' in msg,'>>','')+iif('(:)' in msg,'(:)',''))
133        else :
134            return self
135        # Tr.log
136
137#   def format_msg(self, msg, dpth=2, ops='+fun:ln +wait==') :
138    def format_msg(self, msg, dpth=3, ops='+fun:ln +wait==') :
139        if '(==' in msg :
140            # Начать замер нового интервала
141            self.stms   = self.stms + [perf_counter()]
142            msg = msg.replace( '(==', '(==[' + Tr.format_tm(0) + ']' )
143
144        if '+fun:ln' in ops :
145            frCaller= inspect.stack()[dpth] # 0-format_msg, 1-Tr.log|Tr.TrLiver, 2-log, 3-need func
146            try:
147                cls = frCaller[0].f_locals['self'].__class__.__name__ + '.'
148            except:
149                cls = ''
150            fun     = (cls + frCaller[3]).replace('.__init__','()')
151            ln      = frCaller[2]
152            msg     = '[{}]{}{}:{} '.format( Tr.format_tm( perf_counter() - self.tm ), self.gap, fun, ln ) + msg
153        else :
154            msg     = '[{}]{}'.format( Tr.format_tm( perf_counter() - self.tm ), self.gap ) + msg
155
156        if '+wait==' in ops :
157            if ( '==)' in msg or '==>' in msg ) and len(self.stms)>0 :
158                # Закончить/продолжить замер последнего интервала и вывести его длительность
159                sign    = '==)' if '==)' in msg else '==>'
160                # sign    = icase( '==)' in msg, '==)', '==>' )
161                stm = '[{}]'.format( Tr.format_tm( perf_counter() - self.stms[-1] ) )
162                msg = msg.replace( sign, sign+stm )
163                if '==)' in msg :
164                    del self.stms[-1]
165
166            if '=}}' in msg :
167                # Отменить все замеры
168                self.stms   = []
169
170        return msg.replace('¬',c9).replace('¶',c10)
171        # Tr.format
172
173    @staticmethod
174    def format_tm(secs) :
175        """ Конвертация количества секунд в 12h34'56.78" """
176        if 0==len(Tr.se_fmt) :
177            Tr.se_fmt       = '{:'+str(3+Tr.sec_digs)+'.'+str(Tr.sec_digs)+'f}"'
178            Tr.mise_fmt     = "{:2d}'"+Tr.se_fmt
179            Tr.homise_fmt   = "{:2d}h"+Tr.mise_fmt
180        h = secs // 3600
181        secs = secs % 3600
182        m = secs // 60
183        s = secs % 60
184        return Tr.se_fmt.format(s) \
185                if 0==h+m else \
186               Tr.mise_fmt.format(m,s) \
187                if 0==h else \
188               Tr.homise_fmt.format(h,m,s)
189        # return icase( 0==h+m,   Tr.se_fmt.format(s)
190        #             , 0==h,     Tr.mise_fmt.format(m,s)
191        #             ,           Tr.homise_fmt.format(h,m,s) )
192        # Tr.format_tm
193    # Tr
194
195def get_translation(plug_file):
196    ''' Part of i18n.
197        Full i18n-cycle:
198        1. All GUI-string in code are used in form
199            _('')
200        2. These string are extracted from code to
201            lang/messages.pot
202           with run
203            python.exe <python-root>\Tools\i18n\pygettext.py -p lang <plugin>.py
204        3. Poedit (or same program) create
205            <module>\lang\ru_RU\LC_MESSAGES\<module>.po
206           from (cmd "Update from POT")
207            lang/messages.pot
208           It allows to translate all "strings"
209           It creates (cmd "Save")
210            <module>\lang\ru_RU\LC_MESSAGES\<module>.mo
211        4. <module>.mo can be placed also in dir
212            CudaText\data\langpy\ru_RU\LC_MESSAGES\<module>.mo
213           The dir is used first.
214        5. get_translation uses the file to realize
215            _('')
216    '''
217    lng     = app.app_proc(app.PROC_GET_LANG, '')
218    plug_dir= os.path.dirname(plug_file)
219    plug_mod= os.path.basename(plug_dir)
220    lng_dirs= [
221                app.app_path(app.APP_DIR_DATA)  +os.sep+'langpy',
222                plug_dir                        +os.sep+'lang',
223              ]
224    _       =  lambda x: x
225    pass;                      #return _
226    for lng_dir in lng_dirs:
227        lng_mo  = lng_dir+'/{}/LC_MESSAGES/{}.mo'.format(lng, plug_mod)
228        if os.path.isfile(lng_mo):
229            t   = gettext.translation(plug_mod, lng_dir, languages=[lng])
230            _   = t.gettext
231            t.install()
232            break
233    return _
234   #def get_translation
235
236_   = get_translation(__file__) # I18N
237
238def get_desktop_environment():
239    #From http://stackoverflow.com/questions/2035657/what-is-my-current-desktop-environment
240    # and http://ubuntuforums.org/showthread.php?t=652320
241    # and http://ubuntuforums.org/showthread.php?t=652320
242    # and http://ubuntuforums.org/showthread.php?t=1139057
243    if sys.platform in ["win32", "cygwin"]:
244        return "win"
245    elif sys.platform == "darwin":
246        return "mac"
247    else: #Most likely either a POSIX system or something not much common
248        desktop_session = os.environ.get("DESKTOP_SESSION")
249        if desktop_session is not None: #easier to match if we doesn't have  to deal with character cases
250            desktop_session = desktop_session.lower()
251            if desktop_session in ["gnome","unity", "cinnamon", "mate", "xfce4", "lxde", "fluxbox",
252                                   "blackbox", "openbox", "icewm", "jwm", "afterstep","trinity", "kde"]:
253                return desktop_session
254            ## Special cases ##
255            # Canonical sets $DESKTOP_SESSION to Lubuntu rather than LXDE if using LXDE.
256            # There is no guarantee that they will not do the same with the other desktop environments.
257            elif "xfce" in desktop_session or desktop_session.startswith("xubuntu"):
258                return "xfce4"
259            elif desktop_session.startswith("ubuntu"):
260                return "unity"
261            elif desktop_session.startswith("lubuntu"):
262                return "lxde"
263            elif desktop_session.startswith("kubuntu"):
264                return "kde"
265            elif desktop_session.startswith("razor"): # e.g. razorkwin
266                return "razor-qt"
267            elif desktop_session.startswith("wmaker"): # e.g. wmaker-common
268                return "windowmaker"
269        if os.environ.get('KDE_FULL_SESSION') == 'true':
270            return "kde"
271        elif os.environ.get('GNOME_DESKTOP_SESSION_ID'):
272            if not "deprecated" in os.environ.get('GNOME_DESKTOP_SESSION_ID'):
273                return "gnome2"
274        #From http://ubuntuforums.org/showthread.php?t=652320
275        elif is_running("xfce-mcs-manage"):
276            return "xfce4"
277        elif is_running("ksmserver"):
278            return "kde"
279    return "unknown"
280def is_running(process):
281    #From http://www.bloggerpolis.com/2011/05/how-to-check-if-a-process-is-running-using-python/
282    # and http://richarddingwall.name/2009/06/18/windows-equivalents-of-ps-and-kill-commands/
283    try: #Linux/Unix
284        s = subprocess.Popen(["ps", "axw"],stdout=subprocess.PIPE)
285    except: #Windows
286        s = subprocess.Popen(["tasklist", "/v"],stdout=subprocess.PIPE)
287    for x in s.stdout:
288        if re.search(process, str(x)):
289            return True
290    return False
291
292ENV2FITS= {'win':
293            {'check'      :-2
294            ,'edit'       :-3
295            ,'button'     :-4
296            ,'combo_ro'   :-4
297            ,'combo'      :-3
298            ,'checkbutton':-4
299            ,'linklabel'  : 0
300            ,'spinedit'   :-3
301            }
302          ,'unity':
303            {'check'      :-3
304            ,'edit'       :-5
305            ,'button'     :-4
306            ,'combo_ro'   :-5
307            ,'combo'      :-6
308            ,'checkbutton':-3
309            ,'linklabel'  : 0
310            ,'spinedit'   :-5
311            }
312          ,'mac':
313            {'check'      :-1
314            ,'edit'       :-3
315            ,'button'     :-3
316            ,'combo_ro'   :-2
317            ,'combo'      :-3
318            ,'checkbutton':-2
319            ,'linklabel'  : 0
320            ,'spinedit'   : 0   ##??
321            }
322          }
323fit_top_by_env__cash    = {}
324def fit_top_by_env__clear():
325    global fit_top_by_env__cash
326    fit_top_by_env__cash    = {}
327def fit_top_by_env(what_tp, base_tp='label'):
328    """ Get "fitting" to add to top of first control to vertical align inside text with text into second control.
329        The fittings rely to platform: win, unix(kde,gnome,...), mac
330    """
331    if what_tp==base_tp:
332        return 0
333    if (what_tp, base_tp) in fit_top_by_env__cash:
334        pass;                  #log('cashed what_tp, base_tp={}',(what_tp, base_tp))
335        return fit_top_by_env__cash[(what_tp, base_tp)]
336    env     = get_desktop_environment()
337    pass;                      #env = 'mac'
338    fit4lb  = ENV2FITS.get(env, ENV2FITS.get('win'))
339    fit     = 0
340    if base_tp=='label':
341        fit = apx.get_opt('dlg_wrapper_fit_va_for_'+what_tp, fit4lb.get(what_tp, 0))
342    else:
343        fit = fit_top_by_env(what_tp) - fit_top_by_env(base_tp)
344    pass;                      #log('what_tp, base_tp, fit={}',(what_tp, base_tp, fit))
345    return fit_top_by_env__cash.setdefault((what_tp, base_tp), fit)
346   #def fit_top_by_env
347
348def dlg_wrapper(title, w, h, cnts, in_vals={}, focus_cid=None):
349    """ Wrapper for dlg_custom.
350        Params
351            title, w, h     Title, Width, Height
352            cnts            List of static control properties
353                                [{cid:'*', tp:'*', t:1,l:1,w:1,r:1,b;1,h:1,tid:'cid', cap:'*', hint:'*', en:'0', props:'*', items:[*], act='0'}]
354                                cid         (opt)(str) C(ontrol)id. Need only for buttons and controls with value (and for tid)
355                                tp               (str) Control types from wiki or short names
356                                t           (opt)(int) Top
357                                tid         (opt)(str) Ref to other control cid for horz-align text in both controls
358                                l                (int) Left
359                                r,b,w,h     (opt)(int) Position. w>>>r=l+w, h>>>b=t+h, b can be omitted
360                                cap              (str) Caption for labels and buttons
361                                hint        (opt)(str) Tooltip
362                                en          (opt)('0'|'1'|True|False) Enabled-state
363                                props       (opt)(str) See wiki
364                                act         (opt)('0'|'1'|True|False) Will close dlg when changed
365                                items            (str|list) String as in wiki. List structure by types:
366                                                            [v1,v2,]     For combo, combo_ro, listbox, checkgroup, radiogroup, checklistbox
367                                                            (head, body) For listview, checklistview
368                                                                head    [(cap,width),(cap,width),]
369                                                                body    [[r0c0,r0c1,],[r1c0,r1c1,],[r2c0,r2c1,],]
370            in_vals         Dict of start values for some controls
371                                {'cid':val}
372            focus_cid       (opt) Control cid for start focus
373        Return
374            btn_cid         Clicked/changed control cid
375            {'cid':val}     Dict of new values for the same (as in_vals) controls
376                                Format of values is same too.
377            focus_cid       Focused control cid
378            [cid]           List of controls with changed values
379        Short names for types
380            lb      label
381            ln-lb   linklabel
382            ed      edit
383            sp-ed   spinedit
384            me      memo
385            bt      button
386            rd      radio
387            ch      check
388            ch-bt   checkbutton
389            ch-gp   checkgroup
390            rd-gp   radiogroup
391            cb      combo
392            cb-ro   combo_ro
393            lbx     listbox
394            ch-lbx  checklistbox
395            lvw     listview
396            ch-lvw  checklistview
397        Example.
398            def ask_number(ask, def_val):
399                cnts=[dict(        tp='lb',tid='v',l=3 ,w=70,cap=ask)
400                     ,dict(cid='v',tp='ed',t=3    ,l=73,w=70)
401                     ,dict(cid='!',tp='bt',t=45   ,l=3 ,w=70,cap='OK',props='1')
402                     ,dict(cid='-',tp='bt',t=45   ,l=73,w=70,cap='Cancel')]
403                vals={'v':def_val}
404                while True:
405                    aid,vals,fid,chds=dlg_wrapper('Example',146,75,cnts,vals,'v')
406                    if aid is None or btn=='-': return def_val
407                    if not re.match(r'\d+$', vals['v']): continue
408                    return vals['v']
409    """
410    pass;                      #log('in_vals={}',pformat(in_vals, width=120))
411    cid2i       = {cnt['cid']:i for i,cnt in enumerate(cnts) if 'cid' in cnt}
412    if True:
413        # Checks
414        no_tids = {cnt['tid']   for   cnt in    cnts    if 'tid' in cnt and  cnt['tid'] not in cid2i}
415        if no_tids:
416            raise Exception(f('No cid(s) for tid(s): {}', no_tids))
417        no_vids = {cid          for   cid in    in_vals if                          cid not in cid2i}
418        if no_vids:
419            raise Exception(f('No cid(s) for vals: {}', no_vids))
420    ctrls_l = []
421    for cnt in cnts:
422        tp      = cnt['tp']
423        tp      = REDUCTS.get(tp, tp)
424        if tp=='--':
425            # Horz-line
426            t   = cnt.get('t')
427            l   = cnt.get('l', 0)                   # def: from DlgLeft
428            r   = cnt.get('r', l+cnt.get('w', w))   # def: to   DlgRight
429            lst = ['type=label']
430            lst+= ['cap='+'—'*1000]
431            lst+= ['en=0']
432            lst+= ['pos={l},{t},{r},0'.format(l=l,t=t,r=r)]
433            ctrls_l+= [chr(1).join(lst)]
434            continue#for cnt
435
436        lst     = ['type='+tp]
437        # Simple props
438        for k in ['cap', 'hint']:
439            if k in cnt:
440                lst += [k+'='+str(cnt[k])]
441        # Alexey: support 'ex0'..'ex9'
442        if 'props' in cnt:
443            k = cnt['props'].split(',')
444            for (k_i, k_s) in enumerate(k):
445                lst += ['ex'+str(k_i)+'='+k_s]
446        # Props with preparation
447        # Position:
448        #   t[op] or tid, l[eft] required
449        #   w[idth]  >>> r[ight ]=l+w
450        #   h[eight] >>> b[ottom]=t+h
451        #   b dont need for buttons, edit, labels
452        l       = cnt['l']
453        t       = cnt.get('t', 0)
454        if 'tid' in cnt:
455            # cid for horz-align text
456            bs_cnt  = cnts[cid2i[cnt['tid']]]
457            bs_tp   = bs_cnt['tp']
458            t       = bs_cnt['t'] + fit_top_by_env(tp, REDUCTS.get(bs_tp, bs_tp))
459#           t       = bs_cnt['t'] + top_plus_for_os(tp, REDUCTS.get(bs_tp, bs_tp))
460        r       = cnt.get('r', l+cnt.get('w', 0))
461        b       = cnt.get('b', t+cnt.get('h', 0))
462        lst    += ['pos={l},{t},{r},{b}'.format(l=l,t=t,r=r,b=b)]
463        if 'en' in cnt:
464            val     = cnt['en']
465            lst    += ['en='+('1' if val in [True, '1'] else '0')]
466
467        if 'items' in cnt:
468            items   = cnt['items']
469            if isinstance(items, str):
470                pass
471            elif tp in ['listview', 'checklistview']:
472                # For listview, checklistview: "\t"-separated items.
473                #   first item is column headers: title1+"="+size1 + "\r" + title2+"="+size2 + "\r" +...
474                #   other items are data: cell1+"\r"+cell2+"\r"+...
475                # ([(hd,wd)], [[cells],[cells],])
476                items   = '\t'.join(['\r'.join(['='.join((hd,sz)) for hd,sz in items[0]])]
477                                   +['\r'.join(row) for row in items[1]]
478                                   )
479            else:
480                # For combo, combo_ro, listbox, checkgroup, radiogroup, checklistbox: "\t"-separated lines
481                items   = '\t'.join(items)
482            lst+= ['items='+items]
483
484        # Prepare val
485        if cnt.get('cid') in in_vals:
486            in_val = in_vals[cnt['cid']]
487            if False:pass
488            elif tp in ['check', 'radio', 'checkbutton'] and isinstance(in_val, bool):
489                # For check, radio, checkbutton: value "0"/"1"
490                in_val  = '1' if in_val else '0'
491            elif tp=='memo':
492                # For memo: "\t"-separated lines (in lines "\t" must be replaced to chr(2))
493                if isinstance(in_val, list):
494                    in_val = '\t'.join([v.replace('\t', chr(2)) for v in in_val])
495                else:
496                    in_val = in_val.replace('\t', chr(2)).replace('\r\n','\n').replace('\r','\n').replace('\n','\t')
497            elif tp=='checkgroup' and isinstance(in_val, list):
498                # For checkgroup: ","-separated checks (values "0"/"1")
499                in_val = ','.join(in_val)
500            elif tp in ['checklistbox', 'checklistview'] and isinstance(in_val, tuple):
501                # For checklistbox, checklistview: index+";"+checks
502                in_val = ';'.join( (str(in_val[0]), ','.join( in_val[1]) ) )
503            lst+= ['val='+str(in_val)]
504
505        if 'act' in cnt:    # must be last in lst
506            val     = cnt['act']
507            lst    += ['act='+('1' if val in [True, '1'] else '0')]
508        pass;                  #log('lst={}',lst)
509        ctrls_l+= [chr(1).join(lst)]
510       #for cnt
511    pass;                      #log('ok ctrls_l={}',pformat(ctrls_l, width=120))
512
513    ans     = app.dlg_custom(title, w, h, '\n'.join(ctrls_l), cid2i.get(focus_cid, -1))
514    if ans is None: return None, None, None, None   # btn_cid, {cid:v}, focus_cid, [cid]
515
516    btn_i,  \
517    vals_ls = ans[0], ans[1].splitlines()
518
519    focus_cid   = ''
520    if vals_ls[-1].startswith('focused='):
521        # From API 1.0.156 dlg_custom also returns index of active control
522        focus_n_s   = vals_ls.pop()
523        focus_i     = int(focus_n_s.split('=')[1])
524        focus_cid   = cnts[focus_i].get('cid', '')
525
526    act_cid     = cnts[btn_i]['cid']
527    # Parse output values
528    an_vals = {cid:vals_ls[cid2i[cid]] for cid in in_vals}
529    for cid in an_vals:
530        cnt     = cnts[cid2i[cid]]
531        tp      = cnt['tp']
532        tp      = REDUCTS.get(tp, tp)
533        in_val  = in_vals[cid]
534        an_val  = an_vals[cid]
535        if False:pass
536        elif tp=='memo':
537            # For memo: "\t"-separated lines (in lines "\t" must be replaced to chr(2))
538            if isinstance(in_val, list):
539                an_val = [v.replace(chr(2), '\t') for v in an_val.split('\t')]
540               #in_val = '\t'.join([v.replace('\t', chr(2)) for v in in_val])
541            else:
542                an_val = an_val.replace('\t','\n').replace(chr(2), '\t')
543               #in_val = in_val.replace('\t', chr(2)).replace('\r\n','\n').replace('\r','\n').replace('\n','\t')
544        elif tp=='checkgroup' and isinstance(in_val, list):
545            # For checkgroup: ","-separated checks (values "0"/"1")
546            an_val = an_val.split(',')
547           #in_val = ','.join(in_val)
548        elif tp in ['checklistbox', 'checklistview'] and isinstance(in_val, tuple):
549            an_val = an_val.split(';')
550            an_val = (an_val[0], an_val[1].strip(',').split(','))
551           #in_val = ';'.join(in_val[0], ','.join(in_val[1]))
552        elif isinstance(in_val, bool):
553            an_val = an_val=='1'
554        elif tp=='listview':
555            an_val = -1 if an_val=='' else int(an_val)
556        else:
557            an_val = type(in_val)(an_val)
558        an_vals[cid]    = an_val
559       #for cid
560    chds    = [cid for cid in in_vals if in_vals[cid]!=an_vals[cid]]
561    if focus_cid:
562        # If out focus points to button then will point to a unique changed control
563        focus_tp= cnts[cid2i[focus_cid]]['tp']
564        focus_tp= REDUCTS.get(focus_tp, focus_tp)
565        if focus_tp in ('button'):
566            focus_cid   = '' if len(chds)!=1 else chds[0]
567    return  act_cid \
568        ,   an_vals \
569        ,   focus_cid \
570        ,   chds
571   #def dlg_wrapper
572
573def dlg_valign_consts():
574    pass;                      #log('ok')
575    UP      = '/\\'
576    UP      = '↑↑'
577#   UP      = 'ΛΛΛ'
578    DN      = '\\/'
579    DN      = '↓↓'
580#   DN      = 'VVV'
581    DLG_W,  \
582    DLG_H   = 335, 280
583    fits    = dict(
584               _sp1=fit_top_by_env('check')
585              ,_sp2=fit_top_by_env('edit')
586              ,_sp3=fit_top_by_env('button')
587              ,_sp4=fit_top_by_env('combo_ro')
588              ,_sp5=fit_top_by_env('combo')
589              ,_sp6=fit_top_by_env('checkbutton')
590              ,_sp7=fit_top_by_env('linklabel')
591              ,_sp8=fit_top_by_env('spinedit')
592              )
593    vals    = dict(
594               ch1 =False
595              ,ed2 ='=======?'
596              ,cbo4=0
597              ,cb5 ='=======?'
598              ,chb6=0
599              ,sp8 =4444444
600              )
601    focused = '-'
602    while True:
603        aid, vals, fid, chds = dlg_wrapper(_('Adjust vertical alignments')   ,DLG_W, DLG_H,
604            [dict(cid='lb1'     ,tp='lb'    ,t= 10              ,l=  5  ,w=100  ,cap='==============='                          )
605            ,dict(cid='ch1'     ,tp='ch'    ,t= 10+fits['_sp1'] ,l=115  ,w=100  ,cap='=======?'             ,hint=fits['_sp1']  )
606            ,dict(cid='up1'     ,tp='bt'    ,t= 10-3            ,l=230  ,w=50   ,cap=UP                                         )
607            ,dict(cid='dn1'     ,tp='bt'    ,t= 10-3            ,l=280  ,w=50   ,cap=DN                                         )
608
609            ,dict(cid='lb2'     ,tp='lb'    ,t= 40              ,l=  5  ,w=100  ,cap='==============='                          )
610            ,dict(cid='ed2'     ,tp='ed'    ,t= 40+fits['_sp2'] ,l=115  ,w=100                              ,hint=fits['_sp2']  )
611            ,dict(cid='up2'     ,tp='bt'    ,t= 40-3            ,l=230  ,w=50   ,cap=UP                                         )
612            ,dict(cid='dn2'     ,tp='bt'    ,t= 40-3            ,l=280  ,w=50   ,cap=DN                                         )
613
614            ,dict(cid='lb3'     ,tp='lb'    ,t= 70              ,l=  5  ,w=100  ,cap='==============='                          )
615            ,dict(cid='bt3'     ,tp='bt'    ,t= 70+fits['_sp3'] ,l=115  ,w=100  ,cap='=======?'             ,hint=fits['_sp3']  )
616            ,dict(cid='up3'     ,tp='bt'    ,t= 70-3            ,l=230  ,w=50   ,cap=UP                                         )
617            ,dict(cid='dn3'     ,tp='bt'    ,t= 70-3            ,l=280  ,w=50   ,cap=DN                                         )
618
619            ,dict(cid='lb4'     ,tp='lb'    ,t=100              ,l=  5  ,w=100  ,cap='==============='                          )
620            ,dict(cid='cbo4'    ,tp='cb-ro' ,t=100+fits['_sp4'] ,l=115  ,w=100  ,items=['=======?']         ,hint=fits['_sp4']  )
621            ,dict(cid='up4'     ,tp='bt'    ,t=100-3            ,l=230  ,w=50   ,cap=UP                                         )
622            ,dict(cid='dn4'     ,tp='bt'    ,t=100-3            ,l=280  ,w=50   ,cap=DN                                         )
623
624            ,dict(cid='lb5'     ,tp='lb'    ,t=130              ,l=  5  ,w=100  ,cap='==============='                          )
625            ,dict(cid='cb5'     ,tp='cb'    ,t=130+fits['_sp5'] ,l=115  ,w=100  ,items=['=======?']         ,hint=fits['_sp5']  )
626            ,dict(cid='up5'     ,tp='bt'    ,t=130-3            ,l=230  ,w=50   ,cap=UP                                         )
627            ,dict(cid='dn5'     ,tp='bt'    ,t=130-3            ,l=280  ,w=50   ,cap=DN                                         )
628
629            ,dict(cid='lb6'     ,tp='lb'    ,t=160              ,l=  5  ,w=100  ,cap='==============='                          )
630            ,dict(cid='chb6'    ,tp='ch-bt' ,t=160+fits['_sp6'] ,l=115  ,w=100  ,cap='=======?'             ,hint=fits['_sp6']  )
631            ,dict(cid='up6'     ,tp='bt'    ,t=160-3            ,l=230  ,w=50   ,cap=UP                                         )
632            ,dict(cid='dn6'     ,tp='bt'    ,t=160-3            ,l=280  ,w=50   ,cap=DN                                         )
633
634            ,dict(cid='lb7'     ,tp='lb'    ,t=190              ,l=  5  ,w=100  ,cap='==============='                          )
635            ,dict(cid='chb7'    ,tp='ln-lb' ,t=190+fits['_sp7'] ,l=115  ,w=100  ,cap='=======?',props='-'   ,hint=fits['_sp7']  )
636            ,dict(cid='up7'     ,tp='bt'    ,t=190-3            ,l=230  ,w=50   ,cap=UP                                         )
637            ,dict(cid='dn7'     ,tp='bt'    ,t=190-3            ,l=280  ,w=50   ,cap=DN                                         )
638
639            ,dict(cid='lb8'     ,tp='lb'    ,t=220              ,l=  5  ,w=100  ,cap='4444444444444444'                         )
640            ,dict(cid='sp8'     ,tp='sp-ed' ,t=220+fits['_sp8'] ,l=115  ,w=100  ,props='0,4444444,1'        ,hint=fits['_sp8']  )
641            ,dict(cid='up8'     ,tp='bt'    ,t=220-3            ,l=230  ,w=50   ,cap=UP                                         )
642            ,dict(cid='dn8'     ,tp='bt'    ,t=220-3            ,l=280  ,w=50   ,cap=DN                                         )
643
644            ,dict(cid='save'    ,tp='bt'    ,t=DLG_H-30         ,l=115  ,w=100  ,cap=_('&Save')
645                                                                                ,hint=_('Apply the fittings to controls of all dialogs.'
646                                                                                        '\rCtrl+Click  - Show data to mail report.'))
647            ,dict(cid='-'       ,tp='bt'    ,t=DLG_H-30         ,l=230  ,w=100  ,cap=_('Cancel')        )
648            ], vals, focus_cid=focused)
649        if aid is None or aid=='-':    return#while True
650        scam        = app.app_proc(app.PROC_GET_KEYSTATE, '') if app.app_api_version()>='1.0.143' else ''
651        aid_m       = scam + '/' + aid if scam and scam!='a' else aid   # smth == a/smth
652        focused = chds[0] if 1==len(chds) else focused
653        if aid[:2]=='up' or aid[:2]=='dn':
654            pos = aid[2]
655            fits['_sp'+pos] = fits['_sp'+pos] + (-1 if aid[:2]=='up' else 1)
656
657        if aid_m=='save':
658            ctrls   = ['check'
659                      ,'edit'
660                      ,'button'
661                      ,'combo_ro'
662                      ,'combo'
663                      ,'checkbutton'
664                      ,'linklabel'
665                      ,'spinedit'
666                      ]
667            for ic, nc in enumerate(ctrls):
668                fit = fits['_sp'+str(1+ic)]
669                if fit==fit_top_by_env(nc): continue#for ic, nc
670                apx.set_opt('dlg_wrapper_fit_va_for_'+nc, fit)
671               #for ic, nc
672            fit_top_by_env__clear()
673            break#while
674
675        if aid_m=='c/save': # Report
676            rpt = 'env:'+get_desktop_environment()
677            rpt+= c13+'check:'      +str(fits['_sp1'])
678            rpt+= c13+'edit:'       +str(fits['_sp2'])
679            rpt+= c13+'button:'     +str(fits['_sp3'])
680            rpt+= c13+'combo_ro:'   +str(fits['_sp4'])
681            rpt+= c13+'combo:'      +str(fits['_sp5'])
682            rpt+= c13+'checkbutton:'+str(fits['_sp6'])
683            rpt+= c13+'linklabel:'  +str(fits['_sp7'])
684            rpt+= c13+'spinedit:'   +str(fits['_sp8'])
685            aid_r, *_t = dlg_wrapper(_('Report'), 230,310,
686                 [dict(cid='rprt',tp='me'    ,t=5   ,l=5 ,h=200 ,w=220)
687                 ,dict(           tp='lb'    ,t=215 ,l=5        ,w=220  ,cap=_('Send the report to the address'))
688                 ,dict(cid='mail',tp='ed'    ,t=235 ,l=5        ,w=220)
689                 ,dict(           tp='lb'    ,t=265 ,l=5        ,w=150  ,cap=_('or post it on'))
690                 ,dict(cid='gith',tp='ln-lb' ,t=265 ,l=155      ,w=70   ,cap='GitHub',props='https://github.com/kvichans/cuda_fit_v_alignments/issues')
691                 ,dict(cid='-'   ,tp='bt'    ,t=280 ,l=205-80   ,w=80   ,cap=_('Close'))
692                 ], dict(rprt=rpt
693                        ,mail='kvichans@mail.ru'), focus_cid='rprt')
694#          if aid_r is None or btn_hlp=='-': break#while
695       #while
696   #def dlg_valign_consts
697
698def get_hotkeys_desc(cmd_id, ext_id=None, keys_js=None, def_ans=''):
699    """ Read one or two hotkeys for command
700            cmd_id [+ext_id]
701        from
702            settings\keys.json
703        Return
704            def_ans                     If no  hotkeys for the command
705            'Ctrl+Q'
706            'Ctrl+Q * Ctrl+W'           If one hotkey  for the command
707            'Ctrl+Q/Ctrl+T'
708            'Ctrl+Q * Ctrl+W/Ctrl+T'    If two hotkeys for the command
709    """
710    if keys_js is None:
711        keys_json   = app.app_path(app.APP_DIR_SETTINGS)+os.sep+'keys.json'
712        keys_js     = apx._json_loads(open(keys_json).read()) if os.path.exists(keys_json) else {}
713
714    cmd_id  = f('{},{}', cmd_id, ext_id) if ext_id else cmd_id
715    if cmd_id not in keys_js:
716        return def_ans
717    cmd_keys= keys_js[cmd_id]
718    desc    = '/'.join([' * '.join(cmd_keys.get('s1', []))
719                       ,' * '.join(cmd_keys.get('s2', []))
720                       ]).strip('/')
721    return desc
722   #def get_hotkeys_desc
723
724######################################
725#NOTE: plugins history
726######################################
727PLING_HISTORY_JSON  = app.app_path(app.APP_DIR_SETTINGS)+os.sep+'plugin history.json'
728def get_hist(key_or_path, default=None, module_name='_auto_detect', to_file=PLING_HISTORY_JSON):
729    """ Read from "plugin history.json" one value by string key or path (list of keys).
730        Parameters
731            key_or_path     Key(s) to navigate in json tree
732                            Type: str or [str]
733            default         Value to return  if no suitable node in json tree
734            module_name     Start node to navigate.
735                            If it is '_auto_detect' then name of caller module is used.
736                            If it is None then it is skipped.
737            to_file         Name of file to read. APP_DIR_SETTING will be joined if no full path.
738
739        Return              Found value or default
740
741        Examples (caller module is 'plg')
742        1. If no "plugin history.json"
743                get_hist('k')                   returns None
744                get_hist(['p', 'k'], 0)         returns 0
745        2. If "plugin history.json" contains
746                {"k":1, "plg":{"k":2, "p":{"m":3}, "t":[0,1]}, "q":{"n":4}}
747                get_hist('k', 0, None)          returns 1
748                get_hist('k', 0)                returns 0
749                get_hist('k', 0, 'plg')         returns 2
750                get_hist('k', 0, 'oth')         returns 0
751                get_hist(['p','m'], 0)          returns 3
752                get_hist(['p','t'], [])         returns [0,1]
753                get_hist('q', 0, None)          returns {'n':4}
754                get_hist(['q','n'], 0, None)    returns 4
755    """
756    to_file = to_file   if os.sep in to_file else   app.app_path(app.APP_DIR_SETTINGS)+os.sep+to_file
757    if not os.path.exists(to_file):
758        pass;                  #log('not exists',())
759        return default
760    data    = None
761    try:
762        data    = json.loads(open(to_file).read())
763    except:
764        pass;                   log('not load: {}',sys.exc_info())
765        return default
766    if module_name=='_auto_detect':
767        caller_globals  = inspect.stack()[1].frame.f_globals
768        module_name = inspect.getmodulename(caller_globals['__file__']) \
769                        if '__file__' in caller_globals else None
770        pass;                  #log('module_name={}',(module_name))
771    keys    = [key_or_path] if type(key_or_path)==str   else key_or_path
772    keys    = keys          if module_name is None      else [module_name]+keys
773    parents,\
774    key     = keys[:-1], keys[-1]
775    for parent in parents:
776        data= data.get(parent)
777        if type(data)!=dict:
778            pass;              #log('not dict parent={}',(parent))
779            return default
780    return data.get(key, default)
781   #def get_hist
782
783def set_hist(key_or_path, value, module_name='_auto_detect', kill=False, to_file=PLING_HISTORY_JSON):
784    """ Write to "plugin history.json" one value by key or path (list of keys).
785        If any of node doesnot exist it will be added.
786        Or remove (if kill) one key+value pair (if suitable key exists).
787        Parameters
788            key_or_path     Key(s) to navigate in json tree
789                            Type: str or [str]
790            value           Value to set if suitable item in json tree exists
791            module_name     Start node to navigate.
792                            If it is '_auto_detect' then name of caller module is used.
793                            If it is None then it is skipped.
794            kill            Need to remove node in tree.
795                            if kill==True parm value is ignored
796            to_file         Name of file to write. APP_DIR_SETTING will be joined if no full path.
797
798        Return              value (param)   if !kill and modification is successful
799                            value (killed)  if  kill and modification is successful
800                            None            if  kill and no path in tree (no changes)
801                            KeyError        if !kill and path has problem
802        Return  value
803
804        Examples (caller module is 'plg')
805        1. If no "plugin history.json"  it will become
806            set_hist('k',0,None)        {"k":0}
807            set_hist('k',1)             {"plg":{"k":1}}
808            set_hist('k',1,'plg')       {"plg":{"k":1}}
809            set_hist('k',1,'oth')       {"oth":{"k":1}}
810            set_hist('k',[1,2])         {"plg":{"k":[1,2]}}
811            set_hist(['p','k'], 1)      {"plg":{"p":{"k":1}}}
812
813        2. If "plugin history.json" contains    {"plg":{"k":1, "p":{"m":2}}}
814                                                it will contain
815            set_hist('k',0,None)                {"plg":{"k":1, "p":{"m":2}},"k":0}
816            set_hist('k',0)                     {"plg":{"k":0, "p":{"m":2}}}
817            set_hist('k',0,'plg')               {"plg":{"k":0, "p":{"m":2}}}
818            set_hist('n',3)                     {"plg":{"k":1, "p":{"m":2}, "n":3}}
819            set_hist(['p','m'], 4)              {"plg":{"k":1, "p":{"m":4}}}
820            set_hist('p',{'m':4})               {"plg":{"k":1, "p":{"m":4}}}
821            set_hist(['p','m','k'], 1)          KeyError (old m is not branch node)
822
823        3. If "plugin history.json" contains    {"plg":{"k":1, "p":{"m":2}}}
824                                                it will contain
825            set_hist('k',       kill=True)      {"plg":{       "p":{"m":2}}}
826            set_hist('p',       kill=True)      {"plg":{"k":1}}
827            set_hist(['p','m'], kill=True)      {"plg":{"k":1, "p":{}}}
828            set_hist('n',       kill=True)      {"plg":{"k":1, "p":{"m":2}}}    (nothing to kill)
829    """
830    to_file = to_file   if os.sep in to_file else   app.app_path(app.APP_DIR_SETTINGS)+os.sep+to_file
831    body    = json.loads(open(to_file).read(), object_pairs_hook=odict) \
832                if os.path.exists(to_file) and os.path.getsize(to_file) != 0 else \
833              odict()
834
835    if module_name=='_auto_detect':
836        caller_globals  = inspect.stack()[1].frame.f_globals
837        module_name = inspect.getmodulename(caller_globals['__file__']) \
838                        if '__file__' in caller_globals else None
839    keys    = [key_or_path] if type(key_or_path)==str   else key_or_path
840    keys    = keys          if module_name is None      else [module_name]+keys
841    parents,\
842    key     = keys[:-1], keys[-1]
843    data    = body
844    for parent in parents:
845        if kill and parent not in data:
846            return None
847        data= data.setdefault(parent, odict())
848        if type(data)!=odict:
849            raise KeyError()
850    if kill:
851        if key not in data:
852            return None
853        value       = data.pop(key)
854    else:
855        data[key]   =  value
856    open(to_file, 'w').write(json.dumps(body, indent=2))
857    return value
858   #def set_hist
859######################################
860######################################
861if __name__ == '__main__' :     # Tests
862    pass
863    def test_ask_number(ask, def_val):
864        cnts=[dict(        tp='lb',tid='v',l=3 ,w=70,cap=ask)
865             ,dict(cid='v',tp='ed',t=3    ,l=73,w=70)
866             ,dict(cid='!',tp='bt',t=45   ,l=3 ,w=70,cap='OK',props='1')
867             ,dict(cid='-',tp='bt',t=45   ,l=73,w=70,cap='Cancel')]
868        vals={'v':def_val}
869        while True:
870            btn,vals,fid,chds=dlg_wrapper('Example',146,75,cnts,vals,'v')
871            if btn is None or btn=='-': return def_val
872            if not re.match(r'\d+$', vals['v']): continue
873            return vals['v']
874    ask_number('ask_____________', '____smth')
875