1''' Lib for Plugin
2Authors:
3    Andrey Kvichansky    (kvichans on github.com)
4Version:
5    '1.0.3 2018-02-01'
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, 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
21GAP         = 5
22c13,c10,c9  = chr(13),chr(10),chr(9)
23REDUCTS = {'lb'     :'label'
24        ,  'ln-lb'  :'linklabel'
25        ,  'ed'     :'edit'
26        ,  'sp-ed'  :'spinedit'
27        ,  'me'     :'memo'
28        ,  'bt'     :'button'
29        ,  'rd'     :'radio'
30        ,  'ch'     :'check'
31        ,  'ch-bt'  :'checkbutton'
32        ,  'ch-gp'  :'checkgroup'
33        ,  'rd-gp'  :'radiogroup'
34        ,  'cb'     :'combo'
35        ,  'cb-ro'  :'combo_ro'
36        ,  'lbx'    :'listbox'
37        ,  'ch-lbx' :'checklistbox'
38        ,  'lvw'    :'listview'
39        ,  'ch-lvw' :'checklistview'
40        ,  'tabs'   :'tabs'
41        }
42
43def f(s, *args, **kwargs):return s.format(*args, **kwargs)
44
45def log(msg='', *args, **kwargs):
46    if args or kwargs:
47        msg = msg.format(*args, **kwargs)
48    if Tr.tr is None:
49        Tr.tr=Tr()
50    return Tr.tr.log(msg)
51
52class Tr :
53    tr=None
54    """ Трассировщик.
55        Основной (единственный) метод: log(строка) - выводит указанную строку в лог.
56        Управляется через команды в строках для вывода.
57        Команды:
58            >>  Увеличить сдвиг при выводе будущих строк (пока жив возвращенный объект)
59            (:) Начать замер нового вложенного периода, закончить когда умрет возвращенный объект
60            (== Начать замер нового вложенного периода
61            ==> Вывести длительность последнего периода
62            ==) Вывести длительность последнего периода и закончить его замер
63            =}} Отменить все замеры
64        Вызов log с командой >> (увеличить сдвиг) возвращает объект,
65            который при уничтожении уменьшит сдвиг
66        """
67    sec_digs        = 2                     # Точность отображения секунд, кол-во дробных знаков
68    se_fmt          = ''
69    mise_fmt        = ''
70    homise_fmt      = ''
71    def __init__(self, log_to_file=None) :
72        # Поля объекта
73        self.gap    = ''                # Отступ
74        self.tm     = perf_counter()    # Отметка времени о запуске
75        self.stms   = []                # Отметки времени о начале замера спец.периода
76
77        if log_to_file:
78            logging.basicConfig( filename=log_to_file
79                                ,filemode='w'
80                                ,level=logging.DEBUG
81                                ,format='%(message)s'
82                                ,datefmt='%H:%M:%S'
83                                ,style='%')
84        else: # to stdout
85            logging.basicConfig( stream=sys.stdout
86                                ,level=logging.DEBUG
87                                ,format='%(message)s'
88                                ,datefmt='%H:%M:%S'
89                                ,style='%')
90        # Tr()
91    def __del__(self):
92        logging.shutdown()
93
94    class TrLiver :
95        cnt = 0
96        """ Автоматически сокращает gap при уничножении
97            Показывает время своей жизни"""
98        def __init__(self, tr, ops) :
99            # Поля объекта
100            self.tr = tr
101            self.ops= ops
102            self.tm = 0
103            self.nm = Tr.TrLiver.cnt
104            if '(:)' in self.ops :
105                # Начать замер нового интервала
106                self.tm = perf_counter()
107        def log(self, msg='') :
108            if '(:)' in self.ops :
109                msg = '{}(:)=[{}]{}'.format( self.nm, Tr.format_tm( perf_counter() - self.tm ), msg )
110                logging.debug( self.tr.format_msg(msg, ops='') )
111        def __del__(self) :
112            #pass;                  logging.debug('in del')
113            if '(:)' in self.ops :
114                msg = '{}(:)=[{}]'.format( self.nm, Tr.format_tm( perf_counter() - self.tm ) )
115                logging.debug( self.tr.format_msg(msg, ops='') )
116            if '>>' in self.ops :
117                self.tr.gap = self.tr.gap[:-1]
118
119    def log(self, msg='') :
120        if '(:)' in msg :
121            Tr.TrLiver.cnt += 1
122            msg     = msg.replace( '(:)', '{}(:)'.format(Tr.TrLiver.cnt) )
123        logging.debug( self.format_msg(msg) )
124        if '>>' in msg :
125            self.gap = self.gap + c9
126            # Создаем объект, который при разрушении сократит gap
127        if '>>' in msg or '(:)' in msg:
128            return Tr.TrLiver(self,('>>' if '>>' in msg else '')+('(:)' if '(:)' in msg else ''))
129            # return Tr.TrLiver(self,iif('>>' in msg,'>>','')+iif('(:)' in msg,'(:)',''))
130        else :
131            return self
132        # Tr.log
133
134#   def format_msg(self, msg, dpth=2, ops='+fun:ln +wait==') :
135    def format_msg(self, msg, dpth=3, ops='+fun:ln +wait==') :
136        if '(==' in msg :
137            # Начать замер нового интервала
138            self.stms   = self.stms + [perf_counter()]
139            msg = msg.replace( '(==', '(==[' + Tr.format_tm(0) + ']' )
140
141        if '+fun:ln' in ops :
142            frCaller= inspect.stack()[dpth] # 0-format_msg, 1-Tr.log|Tr.TrLiver, 2-log, 3-need func
143            try:
144                cls = frCaller[0].f_locals['self'].__class__.__name__ + '.'
145            except:
146                cls = ''
147            fun     = (cls + frCaller[3]).replace('.__init__','()')
148            ln      = frCaller[2]
149            msg     = '[{}]{}{}:{} '.format( Tr.format_tm( perf_counter() - self.tm ), self.gap, fun, ln ) + msg
150        else :
151            msg     = '[{}]{}'.format( Tr.format_tm( perf_counter() - self.tm ), self.gap ) + msg
152
153        if '+wait==' in ops :
154            if ( '==)' in msg or '==>' in msg ) and len(self.stms)>0 :
155                # Закончить/продолжить замер последнего интервала и вывести его длительность
156                sign    = '==)' if '==)' in msg else '==>'
157                # sign    = icase( '==)' in msg, '==)', '==>' )
158                stm = '[{}]'.format( Tr.format_tm( perf_counter() - self.stms[-1] ) )
159                msg = msg.replace( sign, sign+stm )
160                if '==)' in msg :
161                    del self.stms[-1]
162
163            if '=}}' in msg :
164                # Отменить все замеры
165                self.stms   = []
166
167        return msg.replace('¬',c9).replace('¶',c10)
168        # Tr.format
169
170    @staticmethod
171    def format_tm(secs) :
172        """ Конвертация количества секунд в 12h34'56.78" """
173        if 0==len(Tr.se_fmt) :
174            Tr.se_fmt       = '{:'+str(3+Tr.sec_digs)+'.'+str(Tr.sec_digs)+'f}"'
175            Tr.mise_fmt     = "{:2d}'"+Tr.se_fmt
176            Tr.homise_fmt   = "{:2d}h"+Tr.mise_fmt
177        h = int( secs / 3600 )
178        secs = secs % 3600
179        m = int( secs / 60 )
180        s = secs % 60
181        return Tr.se_fmt.format(s) \
182                if 0==h+m else \
183               Tr.mise_fmt.format(m,s) \
184                if 0==h else \
185               Tr.homise_fmt.format(h,m,s)
186        # return icase( 0==h+m,   Tr.se_fmt.format(s)
187        #             , 0==h,     Tr.mise_fmt.format(m,s)
188        #             ,           Tr.homise_fmt.format(h,m,s) )
189        # Tr.format_tm
190    # Tr
191
192def get_translation(plug_file):
193    ''' Part of i18n.
194        Full i18n-cycle:
195        1. All GUI-string in code are used in form
196            _('')
197        2. These string are extracted from code to
198            lang/messages.pot
199           with run
200            python.exe <python-root>\Tools\i18n\pygettext.py -p lang <plugin>.py
201        3. Poedit (or same program) create
202            <module>\lang\ru_RU\LC_MESSAGES\<module>.po
203           from (cmd "Update from POT")
204            lang/messages.pot
205           It allows to translate all "strings"
206           It creates (cmd "Save")
207            <module>\lang\ru_RU\LC_MESSAGES\<module>.mo
208        4. <module>.mo can be placed also in dir
209            CudaText\data\langpy\ru_RU\LC_MESSAGES\<module>.mo
210           The dir is used first.
211        5. get_translation uses the file to realize
212            _('')
213    '''
214    lng     = app.app_proc(app.PROC_GET_LANG, '')
215    plug_dir= os.path.dirname(plug_file)
216    plug_mod= os.path.basename(plug_dir)
217    lng_dirs= [
218                app.app_path(app.APP_DIR_DATA)  +os.sep+'langpy',
219                plug_dir                        +os.sep+'lang',
220              ]
221    _       =  lambda x: x
222    pass;                      #return _
223    for lng_dir in lng_dirs:
224        lng_mo  = lng_dir+'/{}/LC_MESSAGES/{}.mo'.format(lng, plug_mod)
225        if os.path.isfile(lng_mo):
226            t   = gettext.translation(plug_mod, lng_dir, languages=[lng])
227            _   = t.gettext
228            t.install()
229            break
230    return _
231   #def get_translation
232
233def get_desktop_environment():
234    #From http://stackoverflow.com/questions/2035657/what-is-my-current-desktop-environment
235    # and http://ubuntuforums.org/showthread.php?t=652320
236    # and http://ubuntuforums.org/showthread.php?t=652320
237    # and http://ubuntuforums.org/showthread.php?t=1139057
238    if sys.platform in ["win32", "cygwin"]:
239        return "win"
240    elif sys.platform == "darwin":
241        return "mac"
242    else: #Most likely either a POSIX system or something not much common
243        desktop_session = os.environ.get("DESKTOP_SESSION")
244        if desktop_session is not None: #easier to match if we doesn't have  to deal with character cases
245            desktop_session = desktop_session.lower()
246            if desktop_session in ["gnome","unity", "cinnamon", "mate", "xfce4", "lxde", "fluxbox",
247                                   "blackbox", "openbox", "icewm", "jwm", "afterstep","trinity", "kde"]:
248                return desktop_session
249            ## Special cases ##
250            # Canonical sets $DESKTOP_SESSION to Lubuntu rather than LXDE if using LXDE.
251            # There is no guarantee that they will not do the same with the other desktop environments.
252            elif "xfce" in desktop_session or desktop_session.startswith("xubuntu"):
253                return "xfce4"
254            elif desktop_session.startswith("ubuntu"):
255                return "unity"
256            elif desktop_session.startswith("lubuntu"):
257                return "lxde"
258            elif desktop_session.startswith("kubuntu"):
259                return "kde"
260            elif desktop_session.startswith("razor"): # e.g. razorkwin
261                return "razor-qt"
262            elif desktop_session.startswith("wmaker"): # e.g. wmaker-common
263                return "windowmaker"
264        if os.environ.get('KDE_FULL_SESSION') == 'true':
265            return "kde"
266        elif os.environ.get('GNOME_DESKTOP_SESSION_ID'):
267            if not "deprecated" in os.environ.get('GNOME_DESKTOP_SESSION_ID'):
268                return "gnome2"
269        #From http://ubuntuforums.org/showthread.php?t=652320
270        elif is_running("xfce-mcs-manage"):
271            return "xfce4"
272        elif is_running("ksmserver"):
273            return "kde"
274    return "unknown"
275def is_running(process):
276    #From http://www.bloggerpolis.com/2011/05/how-to-check-if-a-process-is-running-using-python/
277    # and http://richarddingwall.name/2009/06/18/windows-equivalents-of-ps-and-kill-commands/
278    try: #Linux/Unix
279        s = subprocess.Popen(["ps", "axw"],stdout=subprocess.PIPE)
280    except: #Windows
281        s = subprocess.Popen(["tasklist", "/v"],stdout=subprocess.PIPE)
282    for x in s.stdout:
283        if re.search(process, str(x)):
284            return True
285    return False
286
287ENV2FITS= {'win':
288            {'check'      :-2
289            ,'edit'       :-3
290            ,'button'     :-4
291            ,'combo_ro'   :-4
292            ,'combo'      :-3
293            ,'checkbutton':-4
294            ,'linklabel'  : 0
295            ,'spinedit'   :-3
296            }
297          ,'unity':
298            {'check'      :-3
299            ,'edit'       :-5
300            ,'button'     :-4
301            ,'combo_ro'   :-5
302            ,'combo'      :-6
303            ,'checkbutton':-3
304            ,'linklabel'  : 0
305            ,'spinedit'   :-5
306            }
307          ,'mac':
308            {'check'      :-1
309            ,'edit'       :-3
310            ,'button'     :-3
311            ,'combo_ro'   :-2
312            ,'combo'      :-3
313            ,'checkbutton':-2
314            ,'linklabel'  : 0
315            ,'spinedit'   : 0   ##??
316            }
317          }
318fit_top_by_env__cash    = {}
319def fit_top_by_env__clear():
320    global fit_top_by_env__cash
321    fit_top_by_env__cash    = {}
322def fit_top_by_env(what_tp, base_tp='label'):
323    """ Get "fitting" to add to top of first control to vertical align inside text with text into second control.
324        The fittings rely to platform: win, unix(kde,gnome,...), mac
325    """
326    if what_tp==base_tp:
327        return 0
328    if (what_tp, base_tp) in fit_top_by_env__cash:
329        pass;                  #log('cashed what_tp, base_tp={}',(what_tp, base_tp))
330        return fit_top_by_env__cash[(what_tp, base_tp)]
331    env     = get_desktop_environment()
332    pass;                      #env = 'mac'
333    fit4lb  = ENV2FITS.get(env, ENV2FITS.get('win'))
334    fit     = 0
335    if base_tp=='label':
336        fit = apx.get_opt('dlg_wrapper_fit_va_for_'+what_tp, fit4lb.get(what_tp, 0))
337    else:
338        fit = fit_top_by_env(what_tp) - fit_top_by_env(base_tp)
339    pass;                      #log('what_tp, base_tp, fit={}',(what_tp, base_tp, fit))
340    return fit_top_by_env__cash.setdefault((what_tp, base_tp), fit)
341   #def fit_top_by_env
342
343def dlg_wrapper(title, w, h, cnts, in_vals={}, focus_cid=None):
344    """ Wrapper for dlg_custom.
345        Params
346            title, w, h     Title, Width, Height
347            cnts            List of static control properties
348                                [{cid:'*', tp:'*', t:1,l:1,w:1,r:1,b;1,h:1, cap:'*', hint:'*', en:'0', props:'*', items:[*], valign_to:'cid'}]
349                                cid         (opt)(str) C(ontrol)id. Need only for buttons and controls with value (and for tid)
350                                tp               (str) Control types from wiki or short names
351                                t           (opt)(int) Top
352                                tid         (opt)(str) Ref to other control cid for horz-align text in both controls
353                                l                (int) Left
354                                r,b,w,h     (opt)(int) Position. w>>>r=l+w, h>>>b=t+h, b can be omitted
355                                cap              (str) Caption for labels and buttons
356                                hint        (opt)(str) Tooltip
357                                en          (opt)('0'|'1'|True|False) Enabled-state
358                                props       (opt)(str) See wiki
359                                act         (opt)('0'|'1'|True|False) Will close dlg when changed
360                                items            (str|list) String as in wiki. List structure by types:
361                                                            [v1,v2,]     For combo, combo_ro, listbox, checkgroup, radiogroup, checklistbox
362                                                            (head, body) For listview, checklistview
363                                                                head    [(cap,width),(cap,width),]
364                                                                body    [[r0c0,r0c1,],[r1c0,r1c1,],[r2c0,r2c1,],]
365            in_vals         Dict of start values for some controls
366                                {'cid':val}
367            focus           (opt) Control cid for  start focus
368        Return
369            btn_cid         Clicked/changed control cid
370            {'cid':val}     Dict of new values for the same (as in_vals) controls
371                                Format of values is same too.
372            [cid]           List of controls with changed values
373        Short names for types
374            lb      label
375            ln-lb   linklabel
376            ed      edit
377            sp-ed   spinedit
378            me      memo
379            bt      button
380            rd      radio
381            ch      check
382            ch-bt   checkbutton
383            ch-gp   checkgroup
384            rd-gp   radiogroup
385            cb      combo
386            cb-ro   combo_ro
387            lbx     listbox
388            ch-lbx  checklistbox
389            lvw     listview
390            ch-lvw  checklistview
391        Example.
392            def ask_number(ask, def_val):
393                cnts=[dict(        tp='lb',tid='v',l=3 ,w=70,cap=ask)
394                     ,dict(cid='v',tp='ed',t=3    ,l=73,w=70)
395                     ,dict(cid='!',tp='bt',t=45   ,l=3 ,w=70,cap=_('OK'),props='1')
396                     ,dict(cid='-',tp='bt',t=45   ,l=73,w=70,cap=_('Cancel'))]
397                vals={'v':def_val}
398                while True:
399                    btn,vals=dlg_wrapper('Example',146,75,cnts,vals,'v')
400                    if btn is None or btn=='-': return def_val
401                    if not re.match(r'\d+$', vals['v']): continue
402                    return vals['v']
403    """
404    pass;                      #log('in_vals={}',pformat(in_vals, width=120))
405    cid2i       = {cnt['cid']:i for i,cnt in enumerate(cnts) if 'cid' in cnt}
406    if True:
407        # Checks
408        no_tids = {cnt['tid']   for   cnt in    cnts    if 'tid' in cnt and  cnt['tid'] not in cid2i}
409        if no_tids:
410            raise Exception(f('No cid(s) for tid(s): {}', no_tids))
411        no_vids = {cid          for   cid in    in_vals if                          cid not in cid2i}
412        if no_vids:
413            raise Exception(f('No cid(s) for vals: {}', no_vids))
414    ctrls_l = []
415    for cnt in cnts:
416        tp      = cnt['tp']
417        tp      = REDUCTS.get(tp, tp)
418        if tp=='--':
419            # Horz-line
420            t   = cnt.get('t')
421            l   = cnt.get('l', 0)                   # def: from DlgLeft
422            r   = cnt.get('r', l+cnt.get('w', w))   # def: to   DlgRight
423            lst = ['type=label']
424            lst+= ['cap='+'—'*1000]
425            lst+= ['en=0']
426            lst+= ['pos={l},{t},{r},0'.format(l=l,t=t,r=r)]
427            ctrls_l+= [chr(1).join(lst)]
428            continue#for cnt
429
430        lst     = ['type='+tp]
431        # Simple props
432        for k in ['cap', 'hint', 'props']:
433            if k in cnt:
434                lst += [k+'='+str(cnt[k])]
435        # Props with preparation
436        # Position:
437        #   t[op] or tid, l[eft] required
438        #   w[idth]  >>> r[ight ]=l+w
439        #   h[eight] >>> b[ottom]=t+h
440        #   b dont need for buttons, edit, labels
441        l       = cnt['l']
442        t       = cnt.get('t', 0)
443        if 'tid' in cnt:
444            # cid for horz-align text
445            bs_cnt  = cnts[cid2i[cnt['tid']]]
446            bs_tp   = bs_cnt['tp']
447            t       = bs_cnt['t'] + fit_top_by_env(tp, REDUCTS.get(bs_tp, bs_tp))
448#           t       = bs_cnt['t'] + top_plus_for_os(tp, REDUCTS.get(bs_tp, bs_tp))
449        r       = cnt.get('r', l+cnt.get('w', 0))
450        b       = cnt.get('b', t+cnt.get('h', 0))
451        lst    += ['pos={l},{t},{r},{b}'.format(l=l,t=t,r=r,b=b)]
452        if 'en' in cnt:
453            val     = cnt['en']
454            lst    += ['en='+('1' if val in [True, '1'] else '0')]
455
456        if 'items' in cnt:
457            items   = cnt['items']
458            if isinstance(items, str):
459                pass
460            elif tp in ['listview', 'checklistview']:
461                # For listview, checklistview: "\t"-separated items.
462                #   first item is column headers: title1+"="+size1 + "\r" + title2+"="+size2 + "\r" +...
463                #   other items are data: cell1+"\r"+cell2+"\r"+...
464                # ([(hd,wd)], [[cells],[cells],])
465                items   = '\t'.join(['\r'.join(['='.join((hd,sz)) for hd,sz in items[0]])]
466                                   +['\r'.join(row) for row in items[1]]
467                                   )
468            else:
469                # For combo, combo_ro, listbox, checkgroup, radiogroup, checklistbox: "\t"-separated lines
470                items   = '\t'.join(items)
471            lst+= ['items='+items]
472
473        # Prepare val
474        if cnt.get('cid') in in_vals:
475            in_val = in_vals[cnt['cid']]
476            if False:pass
477            elif tp in ['check', 'radio', 'checkbutton'] and isinstance(in_val, bool):
478                # For check, radio, checkbutton: value "0"/"1"
479                in_val  = '1' if in_val else '0'
480            elif tp=='memo':
481                # For memo: "\t"-separated lines (in lines "\t" must be replaced to chr(2))
482                if isinstance(in_val, list):
483                    in_val = '\t'.join([v.replace('\t', chr(2)) for v in in_val])
484                else:
485                    in_val = in_val.replace('\t', chr(2)).replace('\r\n','\n').replace('\r','\n').replace('\n','\t')
486            elif tp=='checkgroup' and isinstance(in_val, list):
487                # For checkgroup: ","-separated checks (values "0"/"1")
488                in_val = ','.join(in_val)
489            elif tp in ['checklistbox', 'checklistview'] and isinstance(in_val, tuple):
490                # For checklistbox, checklistview: index+";"+checks
491                in_val = ';'.join( (str(in_val[0]), ','.join( in_val[1]) ) )
492            lst+= ['val='+str(in_val)]
493
494        if 'act' in cnt:    # must be last in lst
495            val     = cnt['act']
496            lst    += ['act='+('1' if val in [True, '1'] else '0')]
497        pass;                  #log('lst={}',lst)
498        ctrls_l+= [chr(1).join(lst)]
499       #for cnt
500    pass;                      #log('ok ctrls_l={}',pformat(ctrls_l, width=120))
501
502    ans     = app.dlg_custom(title, w, h, '\n'.join(ctrls_l), cid2i.get(focus_cid, -1))
503    if ans is None: return None, None, None   # btn_cid, {cid:v}, [cid]
504
505    btn_i,  \
506    vals_ls = ans[0], ans[1].splitlines()
507    aid     = cnts[btn_i]['cid']
508    # Parse output values
509    an_vals = {cid:vals_ls[cid2i[cid]] for cid in in_vals}
510    for cid in an_vals:
511        cnt     = cnts[cid2i[cid]]
512        tp      = cnt['tp']
513        tp      = REDUCTS.get(tp, tp)
514        in_val  = in_vals[cid]
515        an_val  = an_vals[cid]
516        if False:pass
517        elif tp=='memo':
518            # For memo: "\t"-separated lines (in lines "\t" must be replaced to chr(2))
519            if isinstance(in_val, list):
520                an_val = [v.replace(chr(2), '\t') for v in an_val.split('\t')]
521               #in_val = '\t'.join([v.replace('\t', chr(2)) for v in in_val])
522            else:
523                an_val = an_val.replace('\t','\n').replace(chr(2), '\t')
524               #in_val = in_val.replace('\t', chr(2)).replace('\r\n','\n').replace('\r','\n').replace('\n','\t')
525        elif tp=='checkgroup' and isinstance(in_val, list):
526            # For checkgroup: ","-separated checks (values "0"/"1")
527            an_val = an_val.split(',')
528           #in_val = ','.join(in_val)
529        elif tp in ['checklistbox', 'checklistview'] and isinstance(in_val, tuple):
530            an_val = an_val.split(';')
531            an_val = (an_val[0], an_val[1].split(','))
532           #in_val = ';'.join(in_val[0], ','.join(in_val[1]))
533        elif isinstance(in_val, bool):
534            an_val = an_val=='1'
535        elif tp=='listview':
536            an_val = -1 if an_val=='' else int(an_val)
537        else:
538            an_val = type(in_val)(an_val)
539        an_vals[cid]    = an_val
540       #for cid
541    return  aid, an_vals, [cid for cid in in_vals if in_vals[cid]!=an_vals[cid]]
542   #def dlg_wrapper
543
544def get_hotkeys_desc(cmd_id, ext_id=None, keys_js=None, def_ans=''):
545    """ Read one or two hotkeys for command
546            cmd_id [+ext_id]
547        from
548            settings\keys.json
549        Return
550            def_ans                     If no  hotkeys for the command
551            'Ctrl+Q'
552            'Ctrl+Q * Ctrl+W'           If one hotkey  for the command
553            'Ctrl+Q/Ctrl+T'
554            'Ctrl+Q * Ctrl+W/Ctrl+T'    If two hotkeys for the command
555    """
556    if keys_js is None:
557        keys_json   = app.app_path(app.APP_DIR_SETTINGS)+os.sep+'keys.json'
558        keys_js     = apx._json_loads(open(keys_json).read()) if os.path.exists(keys_json) else {}
559
560    cmd_id  = f('{},{}', cmd_id, ext_id) if ext_id else cmd_id
561    if cmd_id not in keys_js:
562        return def_ans
563    cmd_keys= keys_js[cmd_id]
564    desc    = '/'.join([' * '.join(cmd_keys.get('s1', []))
565                       ,' * '.join(cmd_keys.get('s2', []))
566                       ]).strip('/')
567    return desc
568   #def get_hotkeys_desc
569
570if __name__ == '__main__' :     # Tests
571    def test_ask_number(ask, def_val):
572        cnts=[dict(        tp='lb',tid='v',l=3 ,w=70,cap=ask)
573             ,dict(cid='v',tp='ed',t=3    ,l=73,w=70)
574             ,dict(cid='!',tp='bt',t=45   ,l=3 ,w=70,cap=_('OK'),props='1')
575             ,dict(cid='-',tp='bt',t=45   ,l=73,w=70,cap=_('Cancel'))]
576        vals={'v':def_val}
577        while True:
578            btn,vals=dlg_wrapper('Example',146,75,cnts,vals,'v')
579            if btn is None or btn=='-': return def_val
580            if not re.match(r'\d+$', vals['v']): continue
581            return vals['v']
582    ask_number('ask_____________', '____smth')
583