From e1e642fb2f8039f72aacbef7585c42aa3f3e71d7 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Wed, 11 Feb 2026 13:36:44 +0100 Subject: [PATCH] beta version of frappy-edit Change-Id: I82b35505207429cddac44d28222e20627b3a90b3 --- paramedit.py => frappy/tools/cfgedit.py | 440 +++++++++++------- frappy/tools/configdata.py | 80 +++- frappy/tools/terminalgui.py | 572 +++++++++++++++--------- 3 files changed, 700 insertions(+), 392 deletions(-) rename paramedit.py => frappy/tools/cfgedit.py (67%) diff --git a/paramedit.py b/frappy/tools/cfgedit.py similarity index 67% rename from paramedit.py rename to frappy/tools/cfgedit.py index e35a23b5..8bb9bb80 100644 --- a/paramedit.py +++ b/frappy/tools/cfgedit.py @@ -4,31 +4,31 @@ import time from subprocess import Popen, PIPE from pathlib import Path from psutil import pid_exists -from frappy.core import Module from frappy.errors import ConfigError from frappy.lib import generalConfig -from frappy.lib.comparestring import compare from frappy.lib import mkthread, formatExtendedTraceback from frappy.config import process_file, to_config_path from frappy.tools.configdata import Value, cfgdata_to_py, cfgdata_from_py, get_datatype, site, ModuleClass, stringtype from frappy.tools.completion import class_completion, recommended_prs -from frappy.tools.terminalgui import Main, Container, LineEdit, MultiLineEdit, MenuItem, Writer, ContextMenu, \ - TextWidget, TextEdit, NameEdit, TextEditCompl, Completion, TitleBar, StatusBar, Widget, ConfirmDialog, \ - K +import frappy.tools.terminalgui as tg +from frappy.tools.terminalgui import Main, MenuItem, TextEdit, PushButton, ModalDialog, KEY + + +KEY.add( + TOGGLE_DETAILED='^t', + NEXT_VERSION='^n', + PREV_VERSION='^b', +) -K.add(TOGGLE_DETAILED='^t', NEXT_VERSION='^n', PREV_VERSION='^b') VERSION_SEPARATOR = "\n''''" TIMESTAMP_FMT = '%Y-%m-%d-%H%M%S' # TODO: -# - ctrl-K / ctrlV/U for cutting/pasting module(s) or parameter(2) -# * separate buffer for modules and parameters? -# * when inserting a parameter which already exists, overwrite the value only -# - implement IO modules -# - version control: create one file, with a separator (save disk space, no numbering housekeeping needed) -# - use Exceptions for ctrl-X and ctrl-C +# - ctrl-K: on previous versions copy and advance to next module # - use also shift-Tab for level up? # - directory to save to +# - remove older versions, for which a newer exist already: a restore removes the older one + def unix_cmd(cmd, *args): out = Popen(cmd.split() + list(args), stdout=PIPE).communicate()[0] @@ -55,7 +55,7 @@ class TopWidget: parent_cls = Main -class Child(Widget): +class Child(tg.Widget): """child widget of NodeWidget ot ModuleWidget""" parent = TopWidget @@ -81,23 +81,21 @@ class HasValue(Child): def validate(self, strvalue, main=None): pname = self.get_name() + valobj = self.valobj + prev = valobj.value, valobj.strvalue, valobj.error try: if pname != 'cls': if self.clsobj != self.parent.clsobj: self.clsobj = self.parent.clsobj - self.log.info('validate %r %r %r %r', self.parent.get_name(), self.get_name(), self.clsobj, self.valobj) - self.valobj.datatype, self.valobj.error = get_datatype( - self.get_name(), self.clsobj, self.valobj.value) - self.log.info('validate %r %r %r %r', self.parent.get_name(), self.get_name(), self.clsobj, self.valobj) - self.valobj.validate_from_string(strvalue) + valobj.datatype, valobj.error = get_datatype( + self.get_name(), self.clsobj, valobj.value) + valobj.validate_from_string(strvalue) self.error = None except Exception as e: self.error = str(e) - if self.get_name() == 'tolerance': - self.log.info('checked %r %r', self.valobj, self.valobj.error) - if main: + if main and (valobj.value, valobj.strvalue, valobj.error) != prev: main.touch() - return strvalue + return valobj.strvalue def check_data(self): self.validate(self.valobj.strvalue) @@ -106,7 +104,7 @@ class HasValue(Child): return self.get_name() and self.valobj.strvalue -class ValueWidget(HasValue, LineEdit): +class ValueWidget(HasValue, tg.LineEdit): fixedname = None def __init__(self, parent, name, valobj, label=None): @@ -119,13 +117,13 @@ class ValueWidget(HasValue, LineEdit): """ self.init_value_widget(parent, valobj) if label is not None: - labelwidget = TextWidget(label) + labelwidget = tg.TextWidget(label) self.fixedname = name else: - labelwidget = NameEdit(name, self.validate_name) + labelwidget = tg.NameEdit(name, self.validate_name) # self.log.info('value widget %r %r', name, self.fixedname) if valobj.completion: - valueedit = TextEditCompl(valobj.strvalue, self.validate, valobj.completion) + valueedit = tg.TextEditCompl(valobj.strvalue, self.validate, valobj.completion) else: valueedit = TextEdit(valobj.strvalue, self.validate) super().__init__(labelwidget, valueedit) @@ -159,7 +157,7 @@ class ValueWidget(HasValue, LineEdit): as_dict[name] = self.valobj -class DocWidget(HasValue, MultiLineEdit): +class DocWidget(HasValue, tg.MultiLineEdit): parent_cls = TopWidget def __init__(self, parent, name, valobj): @@ -176,11 +174,12 @@ class DocWidget(HasValue, MultiLineEdit): config[self.name] = self.valobj -class BaseWidget(TopWidget, Container): +class BaseWidget(TopWidget, tg.Container): """base for Module or Node""" clsobj = None - header = 'Module' + header = None special_names = 'name', 'cls', 'description' + endline_help = 'RET: add module p: add property' def init(self, parent): self.widgets = [] @@ -216,16 +215,22 @@ class BaseWidget(TopWidget, Container): def new_widget(self): raise NotImplementedError - def add_module(self, after_current=False): - modcfg = {'name': Value(''), 'cls': Value(f'{site.frappy_subdir}.'), 'description': Value('')} + def insert_module(self, module, after_current=False): main = self.parent - module = ModuleWidget(main, '', modcfg) main.insert(main.focus + after_current, module) main.set_focus(main.focus + 1) if not after_current: self.set_focus(1) main.advance(-1) - module.set_focus(1) # go to cls widget + # module.set_focus(0) # go to name widget + + def add_module(self, after_current=False): + modcfg = {'name': Value(''), 'cls': Value(f'{site.frappy_subdir}.'), 'description': Value('')} + self.insert_module(ModuleWidget(self.parent, '', modcfg), after_current) + + def add_iomodule(self, after_current=False): + modcfg = {'name': Value(''), 'uri': Value('')} + self.insert_module(IOWidget(self.parent, '', modcfg), after_current) def get_widget_value(self, key): try: @@ -248,14 +253,6 @@ class BaseWidget(TopWidget, Container): else: self.draw_summary(wr, in_focus) - def current_row(self): - main = self.parent - return super().current_row() if main.detailed else 0 - - def height(self): - main = self.parent - return super().height() if main.detailed else 1 - def collect(self, result): name = self.get_name() if name: @@ -280,6 +277,9 @@ class ModuleName(Value): class ModuleWidget(BaseWidget): + header = 'Module' + endline_help = 'RET: add module i: add io module p: add parameter or property' + def __init__(self, parent, name, modulecfg): assert name == modulecfg['name'].value modulecfg['name'] = ModuleName(parent, name) @@ -287,20 +287,24 @@ class ModuleWidget(BaseWidget): self.context_menu = [ MenuItem('add parameter/property', 'p', self.new_widget), MenuItem('add module', 'm', self.add_module), + MenuItem('add io module', 'i', self.add_iomodule), MenuItem('purge empty prs', 'e', self.purge_prs), MenuItem('add recommended prs', '+', self.complete_prs), - MenuItem('cut module', K.CUT, parent.cut_module), + MenuItem('cut module', KEY.CUT, parent.cut_module), ] - clsvalue = modulecfg['cls'] + self.configure_class(modulecfg.get('cls')) + + for name, valobj in modulecfg.items(): + self.add_widget(name, valobj) + self.widgets.append(EndLine(self)) + + def configure_class(self, clsvalue): clsvalue.callback = self.update_cls clsvalue.completion = class_completion clsobj = clsvalue.value if clsobj: self.fixed_names.update({k: k for k, v in recommended_prs(clsobj).items() if v}) - for name, valobj in modulecfg.items(): - self.add_widget(name, valobj) - self.widgets.append(EndModule(self)) def new_widget(self, name='', pos=None): self.add_widget(name, Value('', *get_datatype('', self.clsobj, ''), from_string=True), self.focus) @@ -314,13 +318,21 @@ class ModuleWidget(BaseWidget): def handle(self, main): while True: key = super().handle(main) if main.detailed else main.get_key() - if key in (K.RIGHT, K.TAB): + if key in (KEY.RIGHT, KEY.TAB): main.detailed = True main.status('') main.offset = None # recalculate offset from screen pos else: return key + def current_row(self): + main = self.parent + return super().current_row() if main.detailed else 0 + + def height(self, to_focus=None): + main = self.parent + return super().height(to_focus) if main.detailed else 1 + def check_data(self): """check clsobj is valid and check all params and props""" # clswidget, = self.find_widgets('cls') @@ -356,17 +368,20 @@ class ModuleWidget(BaseWidget): self.widgets = [w for w in self.widgets if w.get_name() not in self.fixed_names and w.is_valid()] self.update_widget_dict() + def draw_summary_right(self, wr): + half = (wr.width - wr.col) // 2 + wr.norm(f"{self.get_widget_value('description').ljust(half)} {self.get_widget_value('cls')} ") + def draw_summary(self, wr, in_focus): wr.startrow() - wr.norm('Module ') + wr.norm(self.header.ljust(7)) name = self.get_widget_value('name') if in_focus: wr.set_cursor_pos() wr.bright(name, round(wr.width * 0.2)) else: wr.norm(name.ljust(round(wr.width * 0.2))) - half = (wr.width - wr.col) // 2 - wr.norm(f"{self.get_widget_value('description').ljust(half)} {self.get_widget_value('cls')} ") + self.draw_summary_right(wr) def collect(self, result): super().collect(result) @@ -375,9 +390,42 @@ class ModuleWidget(BaseWidget): assert result[name].pop('name').value == name -class EndNode(Child): +class IOWidget(ModuleWidget): + header = 'IO' + endline_help = 'RET: add module p: add property' + special_names = 'name', 'uri' + + def __init__(self, parent, name, modulecfg): + urivalue = modulecfg.get('uri') + if urivalue is None: + modulecfg['uri'] = Value('uri', stringtype) + super().__init__(parent, name, modulecfg) + + def add_widget(self, name, valobj, pos=None): + if name != 'cls' and ( + name != 'description' or valobj.strvalue): + super().add_widget(name, valobj) + + def draw_summary_right(self, wr): + half = (wr.width - wr.col) // 2 + ioname = self.get_name() + modules = [w.get_name() for w in self.parent.widgets if w.get_widget_value('io') == ioname] + wr.norm(f"{self.get_widget_value('uri').ljust(half)} for {','.join(modules)} ") + + def collect(self, result): + name = self.get_name() + if name: + super().collect(result) + modcfg = result[name] + modcfg['cls'] = Value('', stringtype) + modcfg.setdefault('description', Value('', stringtype)) + + def configure_class(self, clsvalue): + pass + + +class EndLine(Child): parent_cls = TopWidget - helptext = 'RET: add module p: add parameter or property' def __init__(self, parent): self.init_parent(parent) @@ -388,8 +436,7 @@ class EndNode(Child): if in_focus: wr.set_cursor_pos(wr.leftwidth) wr.col = wr.leftwidth - wr.bright(' ') - wr.norm(' ' + self.helptext) + wr.high(self.parent.endline_help) def collect(self, result): pass @@ -404,52 +451,29 @@ class EndNode(Child): self.showhelp = False while True: key = main.get_key() - if key in (K.RETURN, K.ENTER): + if key in (KEY.RETURN, KEY.ENTER): self.parent.add_module(True) - elif key in (K.UP, K.DOWN, K.QUIT): + elif key in (KEY.UP, KEY.DOWN, KEY.QUIT): return key + elif key == 'i': + self.parent.add_iomodule(True) elif key == 'p': self.parent.new_widget() - return K.GOTO_MAIN + return KEY.GOTO_MAIN return None -class EndModule(EndNode): - parent_cls = ModuleWidget - - -class IOWidget(ModuleWidget): - def __init__(self, parent, name, modulecfg): - assert name == modulecfg['name'].value - modulecfg['name'] = ModuleName(parent, name) - self.init(parent) - self.context_menu = [ - MenuItem('add module', 'm', self.add_module), - MenuItem('cut module', K.CUT, parent.cut_module), - ] - urivalue = modulecfg.get('uri') - if urivalue is None: - urivalue = Value('uri', stringtype) - self.add_widget('uri', urivalue) - self.widgets.append(EndModule(self)) - - -class EndIO(EndNode): - parent_cls = IOWidget - helptext = 'RET: add module' - - class NodeName(Value): def __init__(self, main, name): self.main = main super().__init__(name) - def set_from_string(self, value): - self.error = self.main.set_node_name(value) - if self.error: + def validate_from_string(self, value): + try: + self.main.set_node_name(value) + except Exception: self.value = self.strvalue = self.main.cfgname - else: - self.value = self.strvalue = value + raise class NodeWidget(BaseWidget): @@ -471,7 +495,7 @@ class NodeWidget(BaseWidget): self.widget_dict['doc'] = docwidget else: self.add_widget(name, valobj) - self.widgets.append(EndNode(self)) + self.widgets.append(EndLine(self)) def new_widget(self, name=''): """insert new widget at focus pos""" @@ -481,43 +505,23 @@ class NodeWidget(BaseWidget): return 'node' def set_focus(self, focus, step=1): - self.log.info('node focus %r %r %r', self.focus, focus, step) while super().set_focus(focus, step): - self.log.info('node find %r name %r', self.focus, self.get_focus_widget().get_name()) if self.parent.detailed or self.get_focus_widget().get_name() in self.summ_edit: - self.log.info('found') return True focus = self.focus + step - self.log.info('node focus end %r', self.focus) - def height(self): + def height(self, to_focus=None): main = self.parent - return sum(w.height() for w in self.widgets if main.detailed or w.get_name() in self.summ_edit) - - - # def handle(self, main): - # if main.detailed: - # return super().handle(main) - # advance = self.next - # key = None - # itry = 10 - # while itry > 0: - # widget = self.get_focus_widget() - # if widget.get_name() in ('title', 'doc'): - # key = widget.handle(main) - # itry = 10 - # # if key in (CTRL_X, K.LEFT): - # # return key - # if key == K.UP: - # advance = self.prev - # elif key in (K.DOWN, K.RETURN, K.ENTER): - # advance = self.next - # else: - # return key - # if not advance(): - # return key - # itry -= 1 - # raise ValueError('x') + if not main.detailed: + return super().height(to_focus) + height = 0 + if to_focus is None: + to_focus = len(self.widgets) + for nr, widget in enumerate(self.widgets[:to_focus]): + name = widget.get_name() + if name in self.summ_edit: + height += widget.height() + return height def draw_summary(self, wr, in_focus): # wr.startrow() @@ -530,9 +534,66 @@ class NodeWidget(BaseWidget): widget.draw(wr, nr == focus) +class SaveDialog(ModalDialog): + def __init__(self, filename): + self.fileedit = tg.DialogInput(self, 'file', str(filename)) + self.result = None + super().__init__([self.fileedit, + PushButton(self, 'save and quit', self.save), + PushButton(self, 'quit without saving', self.nosave), + PushButton(self, 'cancel', self.cancel)]) + + def execute(self, main): + self.set_focus(1) # go to save button + super().execute(main) + if self.fileedit.result: + return self.save() + if self.result is None: + # no button was pressed + return self.cancel() + return self.result() + + def save(self): + self.filename = self.fileedit.get_value() + return KEY.QUIT + + def nosave(self): + self.filename = None + return KEY.QUIT + + def cancel(self): + return None + + HELP_TEXT = """ -ctrl-X Exit -ctrl-T Toggle view mode (summary <-> detailed) +Frappy Configuration Editor +--------------------------- + +A configuration files has a Node section, followed by any number of IO and +Module sections. IO section typically just contain the name and an uri. +A Module sections key item is the 'cls', denoting the python class for +the implementation. Entering the class is supported by a completion popup +menu, which opens as soon as you start typing. +When opening a file, the editor is in summary mode, showing a compact +overview over all modules. Use ctrl-T to toggle to detailed view to +be able to edit individual items. + + +Modify entries +-------------- + +To enter a new value a field, start typing. To modify a value press ctrl-A +of ctrl-E to go the the start or end of the string. + + +Context Menu +------------- + +Press ctrl-X to open a context menu. Navigate to an entry an press RETURN +or press the key indicated to the left to execute an action. A key starting +with ^ indicates to the given action may be performed with a ctrl- +directly without preceding ctrl-X. However, within a context menu, +pressing the letter without ctrl works also. """ @@ -548,19 +609,20 @@ class EditorMain(Main): cut_extend = False def __init__(self, cfg): - self.titlebar = TitleBar('Frappy Cfg Editor') - super().__init__([], [self.titlebar], [StatusBar(self)]) + self.titlebar = tg.TitleBar('Frappy Cfg Editor') + super().__init__([], tg.Writer, [self.titlebar], [tg.StatusBar(self)]) # self.select_menu = MenuItem('select module', CUT_KEY) self.version_menu = [ - MenuItem('previous version', K.PREV_VERSION, self.prev_version), - MenuItem('next version', K.NEXT_VERSION, self.next_version), + MenuItem('previous version', KEY.PREV_VERSION, self.prev_version), + MenuItem('next version', KEY.NEXT_VERSION, self.next_version), MenuItem('restore this version', 'r', self.restore_version), + MenuItem('copy module', KEY.CUT, self.cut_module), ] self.main_menu = [ - MenuItem('show previous version', K.PREV_VERSION, self.prev_version), + MenuItem('show previous version', KEY.PREV_VERSION, self.prev_version), ] - self.detailed_menuitem = MenuItem('toggle detailed', K.TOGGLE_DETAILED, self.toggle_detailed) - self.cut_menuitem = MenuItem('insert cut modules', K.PASTE, self.insert_cut) + self.detailed_menuitem = MenuItem('toggle detailed', KEY.TOGGLE_DETAILED, self.toggle_detailed) + self.cut_menuitem = MenuItem('insert cut modules', KEY.PASTE, self.insert_cut) self.version_dir = Path('~/.local/share/frappy_config_editor').expanduser() self.version_dir.mkdir(mode=0o700, parents=True, exist_ok=True) self.cfgname = None @@ -580,35 +642,34 @@ class EditorMain(Main): else: cfgpath = None self.filecontent = None - error = self.set_node_name(cfg, cfgpath) - if error: - raise RuntimeError(error) + self.set_node_name(cfg, cfgpath) self.init_from_content(self.filecontent) self.module_clipboard = {} self.pr_clipboard = {} def get_menu(self): self.detailed_menuitem.name = 'summary view' if self.detailed else 'detailed view' - cmenu = [self.cut_menuitem] if self.cut_modules else [] - vmenu = self.version_menu if self.version_view else self.main_menu - return cmenu + vmenu + [self.detailed_menuitem] + self.context_menu + menu = self.version_menu if self.version_view else self.main_menu + if self.cut_modules and not self.version_view: + self.cut_menuitem.name = f'insert {self.describe_buffer()}' + menu.append(self.cut_menuitem) + return menu + [self.detailed_menuitem] + self.context_menu def init_from_content(self, filecontent): - self.log.info('init from content %r', len(filecontent)) widgets = [] nodedata, moddata = cfgdata_from_py(self.cfgname, self.cfgpath, filecontent, self.log) widgets.append(NodeWidget(self, self.cfgname, nodedata)) for key, modcfg in moddata.items(): clsvalue = modcfg.get('cls') if clsvalue: - if clsvalue.value == '': + if clsvalue.strvalue == '': widgets.append(IOWidget(self, key, modcfg)) continue else: modcfg['cls'] = Value('', ModuleClass, from_string=True) widgets.append(ModuleWidget(self, key, modcfg)) - self.log.info('widgets %r', len(self.widgets)) self.widgets = widgets + # self.dirty = False def toggle_detailed(self): self.detailed = not self.detailed @@ -628,7 +689,7 @@ class EditorMain(Main): else: key = super().get_key() if self.version_view: - if isinstance(key, str) or key in [K.DEL]: + if isinstance(key, str) or key in [KEY.DEL]: self.status('', 'can not edit previous version') else: break @@ -636,6 +697,15 @@ class EditorMain(Main): break return key + def describe_buffer(self,): + cm = self.cut_modules + if not cm: + return '' + if len(cm) > 1: + sep = ',' if len(cm) == 2 else '..' + return f'modules {cm[0].get_name()}{sep}{cm[-1].get_name()}' + return f'module {cm[0].get_name()}' + def cut_module(self): if not self.cut_modules: self.cut_extend = False @@ -643,13 +713,21 @@ class EditorMain(Main): if not isinstance(module, ModuleWidget): self.status('', warn='can not cut node') return - self.widgets[self.focus:self.focus+1] = [] + if not self.version_view: + self.widgets[self.focus:self.focus+1] = [] if not self.cut_extend: self.cut_modules = [] self.log.info('start cut modules') self.cut_modules.append(module) self.cut_extend = True - self.status(f'{len(self.cut_modules)} modules buffered') + if self.version_view: + text = 'copied' + if not self.set_focus(self.focus + 1): + if self.cut_modules[-1] == module: + self.cut_modules.pop() + else: + text = 'cut' + self.status(f'{self.describe_buffer()} {text}') def insert_cut(self): if self.cut_modules: @@ -658,20 +736,12 @@ class EditorMain(Main): def set_node_name(self, name, cfgpath=None): if name == self.cfgname: - return None + return if not name: - return f'{name!r} is not a valid node name' - if self.pidfile: - self.pidfile.unlink() - self.pidfile = self.version_dir / f'{name}.pid' - try: - pidstr = self.pidfile.read_text() - if pid_exists(int(pidstr)): - return f'{name} is already edited by process {pidstr}' - # overwrite pidfile from dead process - except FileNotFoundError: - pass + raise ValueError(f'{name!r} is not a valid node name') + self.write_pidfile(name) self.cfgname = name + self.titlebar.mid = name versions_path = self.version_dir / f'{name}.versions' try: sections = versions_path.read_text().split(VERSION_SEPARATOR) @@ -692,6 +762,8 @@ class EditorMain(Main): try: filecontent = cfgpath.read_text() self.cfgpath = cfgpath + if cfgpath != self.tmppath: + self.titlebar.mid = str(cfgpath) timestamp = time.strftime(TIMESTAMP_FMT, time.localtime(cfgpath.stat().st_mtime)) break except FileNotFoundError: @@ -702,13 +774,15 @@ class EditorMain(Main): self.cfgpath = cfgpaths[0] self.filecontent = filecontent self.add_version(filecontent, timestamp) - return None def add_version(self, filecontent, timestamp): if self.versions: - # remove last version if unchanged - key, content = next(reversed(self.versions.items())) - if content == filecontent: + to_remove = [] + # remove matching versions + for key, content in self.versions.items(): + if content == filecontent: + to_remove.append(key) + for key in to_remove: self.versions.pop(key) if filecontent: self.versions[timestamp] = filecontent @@ -726,8 +800,8 @@ class EditorMain(Main): if not self.version_view: self.status('this is already the current version') return - self.popupmenu = menu = ConfirmDialog('restore this version? [N]') - if not self.popupmenu.handle(self): + self.popupmenu = menu = tg.ConfirmDialog('restore this version? [N]') + if not menu.handle(self): self.status('cancelled restore') return version = list(self.versions)[-self.version_view] @@ -782,15 +856,22 @@ class EditorMain(Main): widget.collect(cfgdata) if 'node' not in cfgdata: raise ValueError(list(cfgdata), len(self.widgets)) - config_code = cfgdata_to_py(**cfgdata) + content = cfgdata_to_py(**cfgdata) # if self.cfgpath: # self.cfgpath.write_text(config_code) - self.tmppath.write_text(config_code) - return config_code + self.tmppath.write_text(content) + self.filecontent = content - def finish(self, exc): - # TODO: ask where to save tmp file + def quit(self): self.save() + savedialog = SaveDialog(self.cfgpath) + if savedialog.execute(self) == KEY.QUIT: + filename = savedialog.filename + if filename: + self.log.info('saved %r to %r', self.cfgname, filename) + Path(filename).write_text(self.filecontent) + return True + return False def advance(self, step): done = super().advance(step) @@ -798,14 +879,41 @@ class EditorMain(Main): self.get_focus_widget().set_focus(None, step) return done + def write_pidfile(self, name): + pidfile = self.version_dir / f'{name}.pid' + mypid = os.getpid() + for itry in range(15): + try: + with open(pidfile, 'x') as f: + f.write(str(mypid)) + if self.pidfile and self.pidfile.exists(): + self.pidfile.unlink() + self.pidfile = pidfile + return None + except FileExistsError: + pass + try: + pid = int(pidfile.read_text()) + if pid == mypid: + if self.pidfile and self.pidfile != pidfile and self.pidfile.exists(): + self.pidfile.unlink() + return None + if pid_exists(pid): + raise FileExistsError(f'{name} is already edited by process {pid}') + pidfile.unlink() + except FileNotFoundError: + pass + time.sleep(itry * 0.01) + raise RuntimeError('pidfile error: too many tries') + def run(self): try: - self.pidfile.write_text(str(os.getpid())) - super().run(Writer) + super().run() except Exception: print(formatExtendedTraceback()) finally: - self.pidfile.unlink() + if self.pidfile: + self.pidfile.unlink() if __name__ == "__main__": diff --git a/frappy/tools/configdata.py b/frappy/tools/configdata.py index 88fc8573..a11d3c0f 100644 --- a/frappy/tools/configdata.py +++ b/frappy/tools/configdata.py @@ -24,11 +24,14 @@ import frappy from pathlib import Path from ast import literal_eval from importlib import import_module -from frappy.config import process_file, Node +from frappy.config import process_file, Node, fix_io_modules from frappy.core import Module from frappy.datatypes import DataType +HEADER = "# please edit this file with frappy edit" + + class Site: domain = 'psi.ch' frappy_subdir = 'frappy_psi' @@ -167,11 +170,8 @@ class Value: def validate_from_string(self, strvalue): self.strvalue = strvalue self.value = self.callback(self.datatype.from_string(strvalue)) - if type(self.datatype).__name__ == 'ClassCompletion': - raise ValueError(self) def set_from_string(self, strvalue): - self.strvalue = strvalue try: self.validate_from_string(strvalue) except Exception as e: @@ -190,7 +190,7 @@ class Value: return repr(self.strvalue) def __repr__(self): - return f'Value({self.value!r}, {self.datatype!r})' + return f'{type(self).__name__}({self.value!r}, {self.datatype!r})' def get_datatype(pname, cls, value): @@ -259,7 +259,6 @@ class ClassChecker: error = None else: error = 'empty element' - if error: self.name = name self.error = error @@ -300,6 +299,8 @@ class ModuleClass(DataType): checker = ClassChecker(value) if checker.error: raise ValueError(checker.error) + if checker.clsobj is None: + raise ValueError(value) return checker.clsobj @classmethod @@ -343,9 +344,15 @@ def moddata_from_cfgfile(name, cls, **kwds): def moddata_to_py(name, cls, description, **kwds): - if '<' in cls.get_repr(): - raise ValueError(cls) - items = [f'Mod({name!r}', cls.get_repr(), description.get_repr()] + if cls.strvalue == '': + uri = kwds.pop('uri') + items = [f'IO({name!r}, {uri.strvalue!r}'] + if description.strvalue: + items.append(f'description={description.strvalue!r}') + else: + if '<' in cls.get_repr(): + raise ValueError(cls) + items = [f'Mod({name!r}', cls.get_repr(), description.get_repr()] paramdict = {} for name, valobj in kwds.items(): param, _, prop = name.partition('.') @@ -364,6 +371,8 @@ def moddata_to_py(name, cls, description, **kwds): # extend with keyworded values for parameter properties args.extend(f'{k}={v.get_repr()}' for k, v in props.items()) items.append(f"{name} = Param({', '.join(args)})") + if len(items) == 1: + return f"{items[0]})" items.append(')') return ',\n '.join(items) @@ -403,8 +412,8 @@ def nodedata_from_cfgfile(name, equipment_id='', description='', interface='', c return {k: Value(v) for k, v in props.items()} -def nodedata_to_py(name, equipment_id, title, doc, interface=None, cls=None, **kwds): - eq_id = fix_equipment_id(name.value, equipment_id.value) +def nodedata_to_py(name, title, doc, equipment_id=None, interface=None, cls=None, **kwds): + eq_id = fix_equipment_id(name.value, equipment_id.value if equipment_id else '') intfc = site.default_interface if interface is None else interface.value desc = title.value.strip() doc = doc.value.strip() @@ -425,18 +434,57 @@ def cfgdata_to_py(node, **moddata): """convert cfgdata to python code :param node: dict of - :param cfgdata: dict of dict of + :param moddata: dict of dict of :return: python code """ - items = [nodedata_to_py(**node)] + [moddata_to_py(k, **v) for k, v in moddata.items()] + items = [HEADER, nodedata_to_py(**node)] + [moddata_to_py(k, **v) for k, v in moddata.items()] return '\n\n'.join(items) def cfgdata_from_py(name, cfgpath, filecontent, logger): if filecontent: config = process_file(cfgpath, logger, filecontent) + iodict = {k: v for k, v in config.items() if v.get('cls') == ''} + nodecfg = config.pop('node', {}) + nodedesc = nodecfg.get('description', '') + if not filecontent.startswith(HEADER) and '\n' not in nodedesc: + nodecfg['description'] = f'{nodedesc}\n\nafter conversion with frappy edit' else: config = {} - nodecfg = config.pop('node', {}) - modcfg = {k: moddata_from_cfgfile(k, **v) for k, v in config.items()} - return nodedata_from_cfgfile(name, **nodecfg), modcfg + iodict = {} + nodecfg = {} + + errors = {} + for modname, modcfg in config.items(): + modio = modcfg.get('io') + if modio: # convert legacy io Mod cfg to IO + ioclass = None + try: + ioname = modio['value'] + if ioname in iodict: + continue + iomodcfg = config[ioname] + if set(iomodcfg) - {'uri', 'cls', 'description'}: + continue + iomodcls = iomodcfg['cls'] + modcls = modcfg['cls'] + ioclass = f"{modcls}.ioClass" + if ModuleClass.validate(iomodcls) != ModuleClass.validate(ioclass): + continue + iomodcfg['cls'] = '' + iomodcfg.pop('description', None) + iodict[ioname] = iomodcfg + except Exception as e: + if ioclass: + iomod, iocls = iomodcls.rsplit('.', 1) + mod, cls = modcls.rsplit('.', 1) + if mod == iomod: + iomodcls = iocls + errors[ioname] = f'{ioname}: missing ioClass={iomodcls} in source code of {modcls}' + else: + logger.info('error %r when checking io for %r', e, modname) + + modules = {k: moddata_from_cfgfile(k, **v) for k, v in config.items()} + for error in errors.values(): + logger.info(error) + return nodedata_from_cfgfile(name, **nodecfg), modules diff --git a/frappy/tools/terminalgui.py b/frappy/tools/terminalgui.py index 29b23a63..fc2653d0 100644 --- a/frappy/tools/terminalgui.py +++ b/frappy/tools/terminalgui.py @@ -24,6 +24,60 @@ import threading from select import select +class KEY: + """helper class for keys + + converts all keys used into an instance of Key + """ + # unfortunately, we can not use some ctrl keys, as they already have a meaning: + # ^H: backspace, ^I: tab, ^M: ret, ^S/^Q: flow control + ESC = 27 + TAB = 9 + DEL = 127 + RETURN = 13 + QUIT = '^q' + BEG_LINE = '^a' + END_LINE = '^e' + MENU = '^x' + CUT = '^k' + PASTE = '^v' + HELP = '^g' + UNHANDLED = -1 + GOTO_MAIN = -2 + GO_UP = -3 + UP = None # get curses.KEY_UP + DOWN = None + LEFT = None + RIGHT = None + ENTER = None + bynumber = {k: chr(k) for k in range(32, 127)} + byname = {} + + @classmethod + def init(cls): + for name in dir(cls): + if name.isupper(): + cls.add_key(name, getattr(cls, name)) + for base in cls.__mro__: + for name in getattr(base, 'from_curses', ()): + cls.add_key(name, getattr(curses, f'KEY_{name}')) + + @classmethod + def add_key(cls, name, nr): + if isinstance(nr, str): + assert nr[0] == '^' + nr = ord(nr[1]) & 0x1f + elif nr is None: + nr = getattr(curses, f'KEY_{name}') + cls.byname[name] = cls.bynumber[nr] = key = Key(name, nr) + setattr(cls, name, key) + + @classmethod + def add(cls, **kwds): + for name, nr in kwds.items(): + cls.add_key(name, nr) + + class Key(int): def __new__(cls, name, nr): if isinstance(nr, str): @@ -33,41 +87,18 @@ class Key(int): key.name = name return key - def __repr__(self): + def short(self): + """as __repr__, but ctrl keys are translated to ^""" + if 0 <= self < 32: + if 0 < self <= 26: + return f'^{chr(96 + self)}' + return f'^{chr(64 + self)}' return self.name -class Keys: - def __init__(self, **kwds): - # mapping int -> letter - self.bynumber = {k: chr(k) for k in range(32, 127)} - self.keys = {} - self.add(**kwds) + def __repr__(self): + """name by function""" + return self.name - def add(self, **kwds): - toadd = [(k, Key(k, v)) for k, v in kwds.items()] - self.keys.update(toadd) - self.bynumber.update((int(v), v) for _, v in toadd) - - def __getattr__(self, name): - try: - return self.keys[name] - except KeyError: - nr = getattr(curses, f'KEY_{name}', None) - if nr is None: - raise AttributeError(f'no key K.{name} {self.keys}') - self.keys[name] = key = Key(name, nr) - return key - - - -# unfortunately, we can not use some ctrl keys, as they already have a meaning: -# H: backspace, I: tab, M: ret - -K = Keys(ESC=27, TAB=9, DEL=127, RETURN=13, - QUIT='^x', BEG_LINE = '^a', END_LINE = '^e', MENU = '^c', - CUT = '^k', PASTE = '^v', HELP = '^g', - UNHANDLED=-1, GOTO_MAIN=-2, GO_UP=-3, - ) def clamp(*args): return sorted(args)[len(args) // 2] @@ -100,6 +131,7 @@ class Widget: default_height = 1 log = Logger() context_menu = None + default_width = 21 def get_menu(self): if self.context_menu: @@ -118,10 +150,13 @@ class Widget: """returns current row""" return 0 - def height(self): + def height(self, to_focus=None): """returns current height""" return self.default_height + def width(self): + return self.default_width + # def draw(self, wr, in_focus=False): # raise NotImplementedError @@ -131,23 +166,22 @@ class HasWidgets: widgets = None # list of subwidgets def current_row(self): - height = sum(w.height() for w in self.widgets[:self.focus]) - return height + self.get_focus_widget().current_row() + return self.height(self.focus) + self.get_focus_widget().current_row() - def height(self): - return sum(w.height() for w in self.widgets) + def height(self, to_focus=None): + if to_focus is None: + to_focus = len(self.widgets) + return sum(w.height() for w in self.widgets[:to_focus]) def handle(self, main): try: while True: main.current_widget = self.get_focus_widget() key = main.current_widget.handle(main) - # if key in (CTRL_X, K.LEFT): - # return key - if key == K.UP: + if key == KEY.UP: if self.advance(-1): continue - elif key in (K.DOWN, K.RETURN, K.ENTER): + elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER): if self.advance(1): continue return key @@ -199,17 +233,20 @@ class Container(HasWidgets, Widget): class TitleBar(Widget): default_height = 1 - def __init__(self, left, right=''): + def __init__(self, left, mid='', right=''): self.left = left + self.mid = mid self.right = right def draw(self, wr, in_focus=False): + left, mid, right, wid = self.left, self.mid, self.right, wr.width - 1 + midleft = (wid - len(mid) + 1) // 2 + if len(left) < midleft and midleft + len(mid) + len(right) < wid: + text = (left.ljust(midleft) + mid).ljust(wid - len(right)) + right + else: + text = left.ljust(wid - len(right)) + right wr.startrow() - text = self.left.ljust(wr.width) - wr.bar(text) - if self.right: - wr.col = wr.width - len(self.right) - 3 - wr.bar(f' {self.right} ') + wr.bar(text + ' ') class StatusBar(Widget): @@ -233,7 +270,6 @@ class StatusBar(Widget): else: wr.wr(self.text[:wid].rjust(wid) + ' ', wr.barstyle) - def set(self, text, warn=None, query=False): self.text = text self.warn = warn @@ -257,6 +293,9 @@ class TextEdit(Widget): self.col_offset = 0 self.finish_callback = finish_callback + def width(self): + return max(self.minwidth, len(self.value)) + def draw(self, wr, in_focus=False): text = self.value[self.col_offset:] if in_focus: @@ -291,22 +330,22 @@ class TextEdit(Widget): try: while True: key = self.get_key(main) - if key == K.LEFT: + if key == KEY.LEFT: if self.highlighted: return key self.pos = max(0, self.pos - 1) continue - if key == K.TAB: + if key == KEY.TAB: self.highlighted = not self.highlighted self.pos = 0 continue - if key == K.BEG_LINE: + if key == KEY.BEG_LINE: self.pos = 0 - elif key == K.END_LINE: + elif key == KEY.END_LINE: self.pos = len(self.value) - elif key == K.RIGHT: + elif key == KEY.RIGHT: self.pos = len(self.value) if self.highlighted else min(len(self.value), self.pos + 1) - elif key == K.DEL: + elif key == KEY.DEL: if self.highlighted: self.value = '' self.pos = 0 @@ -319,9 +358,9 @@ class TextEdit(Widget): self.value = self.value[:self.pos] + key + self.value[self.pos:] self.pos += 1 elif key is not None: - if key == K.ESC: + if key == KEY.ESC: save = False - return K.DOWN + return KEY.DOWN return key self.highlighted = False finally: @@ -344,8 +383,8 @@ class TextEdit(Widget): class NameEdit(TextEdit): def get_key(self, main): key = super().get_key(main) - if key == K.TAB: - key = K.ENTER + if key == KEY.TAB: + key = KEY.ENTER return key @@ -368,11 +407,6 @@ class TextEditCompl(TextEdit): self.completion_pos = 0 self.completion = completion - def finish(self, value, main): - result = super().finish(value, main) - self.log.info('finish %r %r', value, result) - return result - def enter(self, main): main.popupmenu = None @@ -397,35 +431,39 @@ class TextEditCompl(TextEdit): self.get_selection_menu(main, self.value) key = super().get_key(main) self.menu = menu = main.popupmenu - if not menu or self.highlighted or key != K.DOWN or self.pos < self.completion_pos: + if not menu or self.highlighted or key != KEY.DOWN or self.pos < self.completion_pos: self.menu = main.popupmenu = None return key menu.pos = 1 key = menu.get_key(main) # we have left the popup menu with returned key - if key == K.UP: + if key == KEY.UP: return None - if key == K.LEFT: + if key == KEY.LEFT: self.pos = self.completion_pos return key value = self.value[:self.completion_pos] - if key in (K.TAB, K.RETURN, K.ENTER, K.RIGHT): - selected = menu.get_value() + if key in (KEY.TAB, KEY.RETURN, KEY.ENTER, KEY.RIGHT): + selected = menu.get_value() value += selected - if key == K.TAB or (key in (K.ENTER, K.RETURN) + if key == KEY.TAB or (key in (KEY.ENTER, KEY.RETURN) and self.completion(value)[0] < len(value)): - key = K.RIGHT + key = KEY.RIGHT self.value = value self.pos = len(value) self.menu = main.popupmenu = None return key + class TextWidget(Widget): # minwidth = 16 def __init__(self, text): self.value = text + def width(self): + return len(self.value) + def draw(self, wr, in_focus=False): wr.norm(self.value) @@ -438,6 +476,9 @@ class LineEdit(Widget): self.valuewidget = valuewidget self.focus = 1 + def width(self): + return self.labelwidget.width() + 2 + self.valuewidget.width() + def handle(self, main): name = self.labelwidget.value if isinstance(self.labelwidget, TextEdit) else None if name == '': @@ -445,14 +486,14 @@ class LineEdit(Widget): while True: if self.focus: key = self.valuewidget.handle(main) - if key == K.LEFT: + if key == KEY.LEFT: if name is not None: self.focus = 0 continue return key key = self.labelwidget.handle(main) self.focus = 1 - if key in (K.ENTER, K.RETURN, K.TAB): + if key in (KEY.ENTER, KEY.RETURN, KEY.TAB): key = None return key @@ -478,8 +519,10 @@ class MultiLineEdit(Container): self.validator = validator self.highlighted = False self.cut_menuitems = [ - MenuItem('cut line(s)', K.CUT), - MenuItem('paste line(s)', K.PASTE)] + MenuItem('cut line(s)', KEY.CUT), + MenuItem('paste line(s)', KEY.PASTE), + MenuItem('go to end of line', KEY.END_LINE), + MenuItem('back to start of line', KEY.BEG_LINE)] def get_menu(self): return self.cut_menuitems + self.parent.get_menu() @@ -507,10 +550,10 @@ class MultiLineEdit(Container): self.highlighted = True key = main.get_key() self.highlighted = False - if key in (K.RIGHT, K.TAB): + if key in (KEY.RIGHT, KEY.TAB): self.set_focus(None, -1) - self.next_key = K.RIGHT - elif isinstance(key, str) or key == K.DEL: + self.next_key = KEY.RIGHT + elif isinstance(key, str) or key == KEY.DEL: self.set_focus(None, -1) last = self.get_focus_widget() if self.focus == 0: @@ -520,21 +563,21 @@ class MultiLineEdit(Container): self.widgets.append(LineOfMultiline(self, '')) self.advance(1) self.next_key = key - elif key == K.LEFT: + elif key == KEY.LEFT: self.set_focus(None, 0) - self.next_key = K.LEFT + self.next_key = KEY.LEFT else: return key while True: widget = self.get_focus_widget() key = widget.handle(main) - if key == K.UP: + if key == KEY.UP: if self.advance(-1): continue - elif key in (K.DOWN, K.RETURN, K.ENTER): + elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER): if self.advance(1): continue - elif key == K.GO_UP: + elif key == KEY.GO_UP: continue return key @@ -564,12 +607,12 @@ class LineOfMultiline(TextEdit): else: self.parent.next_key = None multiline = self.parent - if key == K.RETURN: + if key == KEY.RETURN: self.value, nextline = self.value[:self.pos], self.value[self.pos:] multiline.widgets.insert(multiline.focus + 1, LineOfMultiline(multiline, nextline)) main.cursor_col = 0 - return K.DOWN - if key == K.DEL: + return KEY.DOWN + if key == KEY.DEL: if self.pos == 0: if multiline.focus > 0: thisline = self.value @@ -578,21 +621,19 @@ class LineOfMultiline(TextEdit): pos = len(prev.value) prev.value += thisline main.cursor_col = pos - return K.UP - elif key == K.LEFT: + return KEY.UP + elif key == KEY.LEFT: if self.pos == 0 and multiline.focus > 0: prev = multiline.widgets[multiline.focus - 1] main.cursor_col = len(prev.value) - # self.log.info('LEFT %r prev=%r', main.cursor_col, prev.value) - return K.UP - elif key == K.RIGHT: + return KEY.UP + elif key == KEY.RIGHT: if self.pos == len(self.value) and multiline.focus < len(multiline.widgets)-1: - # self.log.info('RIGHT %r focus=%r', main.cursor_col, multiline.focus) main.cursor_col = 0 - return K.DOWN - elif key in (K.UP, K.DOWN): + return KEY.DOWN + elif key in (KEY.UP, KEY.DOWN): return key - elif key == K.CUT: + elif key == KEY.CUT: if not main.cut_lines: main.cut_extend = False if not main.cut_extend: @@ -602,12 +643,11 @@ class LineOfMultiline(TextEdit): multiline.widgets[i:i+1] = [] main.cut_extend = True main.status(f'{len(main.cut_lines)} lines buffered') - return K.GO_UP - elif key == K.PASTE: + return KEY.GO_UP + elif key == KEY.PASTE: multiline.widgets[multiline.focus: multiline.focus] = [ LineOfMultiline(multiline, v) for v in main.cut_lines] - return K.GO_UP - # self.log.info('SET COL %r', self.pos) + return KEY.GO_UP self.cursor_col = self.pos return key @@ -636,9 +676,9 @@ class PopUpMenu: def get_key(self, main): while True: key = main.get_key() - if key == K.DOWN: + if key == KEY.DOWN: self.advance(1) - elif key == K.UP: + elif key == KEY.UP: self.advance(-1) else: return key @@ -661,7 +701,7 @@ class CompletionMenu(PopUpMenu): height = self.height + 1 if self.pos == 0: # rectangle is open on top - wr.rectangle(row - self.pos - 1, col, height, self.width + 1, row + 1) + wr.rectangle(row - self.pos - 1, col, height, self.width + 1, top=row + 1) else: wr.rectangle(row - self.pos - 1, col, height, self.width + 1) for i, value in enumerate(self.selection): @@ -673,6 +713,113 @@ class CompletionMenu(PopUpMenu): wr.menu(value.ljust(self.width)) +class DialogInput(Widget): + """this is used as part of a modal dialog + + pressing return will leave the modal dialog + """ + def __init__(self, parent, label, value): + self.init_parent(parent, ModalDialog) + self.label = label + self.valuewidget = TextEdit(value) + self.result = None + + def get_value(self): + return self.valuewidget.value + + def draw(self, wr, in_focus=False): + # gap = wr.width - self.width() - 2 - wr.left + # if gap > 0: + # self.valuewidget.minwidth = self.valuewidget.width() + gap + wr.startrow() + wr.menu(f'{self.label}: ') + self.valuewidget.draw(wr, in_focus) + + def width(self): + return len(self.label) + 2 + self.valuewidget.width() + + def handle(self, main): + prev_cursor = main.cursor_pos + try: + key = self.valuewidget.handle(main) + if key in (KEY.RETURN, KEY.ENTER): + self.result = self.valuewidget.value + return KEY.GOTO_MAIN + return key + finally: + main.cursor_pos = prev_cursor + + +class PushButton(Widget): + def __init__(self, parent, label, arg, startline=True): + super().init_parent(parent, ModalDialog) + self.label = label + self.arg = arg + self.startline = startline + + def width(self): + return len(self.label) + (0 if self.startline else 1) + + def height(self): + return 1 if self.startline else 0 + + def draw(self, wr, in_focus=False): + if self.startline: + wr.startrow() + else: + wr.menu(' ') + if in_focus: + wr.high(self.label) + else: + wr.button(self.label) + + def handle(self, main): + key = main.get_key() + if key in (KEY.RETURN, KEY.ENTER, 'y', 'Y'): + self.parent.result = self.arg + return KEY.GOTO_MAIN + # if key == KEY.LEFT: + # return KEY.UP + # if key == KEY.RIGHT: + # return KEY.DOWN + return key + + +class ModalDialog(Container): + result = None + + def __init__(self, widgets): + self.widgets = widgets + self.calc_size() + + def calc_size(self): + self.height = sum(w.height() for w in self.widgets) + + def execute(self, main): + try: + main.popupmenu = self + while True: + key = self.handle(main) + if key in (KEY.GOTO_MAIN, KEY.QUIT): + return key + finally: + main.popupmenu = None + + def get_menu(self): + return None + + def draw(self, wr, in_focus=True): + wr.move(wr.row, 1) + self.calc_size() + wr.rectangle(wr.row - 1, wr.col - 1, self.height + 1, wr.width - 1, wr.menustyle) + prevrow = wr.nextrow + for _ in range(self.height): + wr.startrow() + wr.menu(' ' * (wr.width - 2)) + wr.nextrow = prevrow + self.draw_widgets(wr, in_focus) + + class ConfirmDialog(Widget): def __init__(self, query, positive_answers=('Y', 'y')): self.query = query @@ -697,7 +844,7 @@ class ConfirmDialog(Widget): class MenuItem: - def __init__(self, name, shortcut=None, action=None, returnkey=K.GOTO_MAIN): + def __init__(self, name, shortcut=None, action=None, returnkey=KEY.GOTO_MAIN): """a menu item for a context menu :param name: the displayed command name @@ -710,7 +857,7 @@ class MenuItem: self.shortcut = shortcut or '' if shortcut: if isinstance(shortcut, Key): - self.shortcut = f'^{chr(shortcut+96)}' + self.shortcut = shortcut.short() key = shortcut letter = chr(96 + shortcut) self.keys = (shortcut, letter) @@ -724,7 +871,6 @@ class MenuItem: self.returnkey = returnkey else: self.returnkey = key - self.width = len(name) def do_action(self): if self.action: @@ -743,14 +889,13 @@ class ContextMenu(PopUpMenu): """ self.menuitems = [MenuItem('')] + menuitems self.height = len(self.menuitems) - self.width = max(v.width for v in menuitems) # self.selection = [v.name for v in menuitems] self.hotkeys = {k: m for m in menuitems for k in m.keys} def do_hotkey(self, key): menuitem = self.hotkeys.get(key) if not menuitem: - return K.UNHANDLED + return KEY.UNHANDLED return menuitem.do_action() def do_action(self): @@ -765,20 +910,21 @@ class ContextMenu(PopUpMenu): wr.left = max(1, wr.left) wr.nextrow = row = row + fixrow - toprow col = wr.left - 1 + width = max(len(v.name) for v in self.menuitems) for i, menuitem in enumerate(self.menuitems): wr.startrow() wr.menu(menuitem.shortcut.ljust(2)) wr.norm(' ') if i == self.pos: - wr.high(menuitem.name, self.width) + wr.high(menuitem.name, width) else: - wr.menu(menuitem.name, self.width) - wr.rectangle(row, col, height - 1, self.width + 4) + wr.menu(menuitem.name, width) + wr.rectangle(row, col, height - 1, width + 4) class BaseWriter: """base for writer. does nothing else than keeping track of position""" - highstyle = editstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = None + highstyle = brightstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = None errorflag = '! ' def __init__(self): @@ -805,7 +951,7 @@ class BaseWriter: self.bright(text, width) def bright(self, text, width=0): - self.wr(text.ljust(width), self.editstyle, extend_to=width) + self.wr(text.ljust(width), self.brightstyle, extend_to=width) def high(self, text, width=0): self.wr(text.ljust(width), self.highstyle, extend_to=width) @@ -813,6 +959,9 @@ class BaseWriter: def menu(self, text, width=0): self.wr(text, self.menustyle, extend_to=width) + def button(self, text, width=0): + self.wr(text, self.buttonstyle, extend_to=width) + def error(self, text, width=0): self.wr(f'{self.errorflag}{text}', self.errorstyle, extend_to=width) @@ -843,9 +992,9 @@ class BaseWriter: class Writer(BaseWriter): highstyle = curses.A_REVERSE - menustyle = curses.A_BOLD + buttonstyle = curses.A_BOLD barstyle = curses.A_REVERSE - editstyle = 0 + brightstyle = menustyle = 0 errorstyle = 0 errorflag = '! ' newoffset = None @@ -922,14 +1071,14 @@ class Writer(BaseWriter): dim_white = (680, 680, 680) stdscr.bkgd(' ', cls.make_pair(black, dim_white)) bright_white = 1000, 1000, 1000 - cls.editstyle = cls.make_pair(black, bright_white) + cls.menustyle = cls.brightstyle = cls.make_pair(black, bright_white) red = cls.make_color((680, 0, 0)) cls.errorstyle = cls.make_pair(red, dim_white) cls.errorflag = '' very_light_blue = (800, 900, 1000) cls.highstyle = cls.make_pair(black, very_light_blue) - light_white = (900, 900, 900) - cls.menustyle = cls.make_pair(black, light_white) + light_white = (800, 800, 800) + cls.buttonstyle = cls.make_pair(black, light_white) light_green = 0, 1000, 0 cls.querystyle = cls.make_pair(black, light_green) yellow = 1000, 1000, 0 @@ -957,7 +1106,7 @@ class Writer(BaseWriter): self.cursor_visible = visible self.main.cursor_pos = self.row - self.offset, self.col + pos - def vline(self, row, col, length, top, bottom, left, right): + def vline(self, row, col, length, top, bottom, left, right, *attr): """draw a vertical line :param row, col: upper start point @@ -978,10 +1127,10 @@ class Writer(BaseWriter): length -= top - beg beg = None if length > 0: - self.scr.vline(row, col, curses.ACS_VLINE, length) + self.scr.vline(row, col, curses.ACS_VLINE, length, *attr) return beg, end - def hline(self, row, col, length, top, bottom, left, right): + def hline(self, row, col, length, top, bottom, left, right, *attr): """draw a horizontal line :param row, col: left start point @@ -1002,36 +1151,34 @@ class Writer(BaseWriter): length -= left - beg beg = None if length > 0: - self.scr.hline(row, col, curses.ACS_HLINE, length) + self.scr.hline(row, col, curses.ACS_HLINE, length, *attr) else: raise ValueError(length) return None, None - if end is None and beg is not None: - raise ValueError('end None') return beg, end - def rectangle(self, row, col, height, width, top=None, bottom=None, left=None, right=None): + def rectangle(self, row, col, height, width, *attr, **clip): """clipped rectangle""" row = row - self.offset - clip = ( - max(0, (top or 0) - self.offset), - self.height if bottom is None else min(self.height, bottom - self.offset), - max(0, (left or 0)), - self.width if right is None else min(self.width, right) - ) - self.vline(row, col, height, *clip) - self.vline(row, col + width, height, *clip) - left, right = self.hline(row, col, width, *clip) + args = ( + max(0, clip.get('top', 0) - self.offset), + min(self.height, clip.get('bottom', self.height + self.offset) - self.offset), + max(0, clip.get('left', 0)), + min(self.width, clip.get('right', self.width)) + ) + attr + self.vline(row, col, height, *args) + self.vline(row, col + width, height, *args) + left, right = self.hline(row, col, width, *args) if left is not None: - self.scr.addch(row, left, curses.ACS_ULCORNER) + self.scr.addch(row, left, curses.ACS_ULCORNER, *attr) if right is not None: - self.scr.addch(row, right, curses.ACS_URCORNER) + self.scr.addch(row, right, curses.ACS_URCORNER, *attr) row += height - left, right = self.hline(row, col, width, *clip) + left, right = self.hline(row, col, width, *args) if left is not None: - self.scr.addch(row, left, curses.ACS_LLCORNER) + self.scr.addch(row, left, curses.ACS_LLCORNER, *attr) if right is not None: - self.scr.addch(row, right, curses.ACS_LRCORNER) + self.scr.addch(row, right, curses.ACS_LRCORNER, *attr) class Empty(Widget): @@ -1042,20 +1189,27 @@ class Empty(Widget): HELP_TEXT = """ -ctrl-X Exit -ctrl-C context menu +ctrl-X Context Menu +ctrl-Q Quit """ + class Main(HasWidgets): parent = None popupmenu = None statusbar = None menubar = None + context_menu = None help_text = HELP_TEXT log = Widget.log leftwidth = 0.2 # if < 1: a fraction - def __init__(self, widgets, /, headers=(), footers=()): + def __init__(self, widgets, writercls, headers=(), footers=()): + #KEY.add( + # ESC=27, TAB=9, DEL=127, RETURN=13, + # QUIT='^x', BEG_LINE='^a', END_LINE='^e', MENU='^c', CUT='^k', PASTE='^v', HELP='^g', + # UNHANDLED=-1, GOTO_MAIN=-2, GO_UP=-3) + self.writercls = writercls self.focus = 0 self.headers = headers self.footers = footers @@ -1070,16 +1224,50 @@ class Main(HasWidgets): self.cursor_col = 0 # position of cursor in MultiLineEdit self.cursor_pos = (0, 0) self.popup_offset = 0 - self.quit = False + self.quit_action = None self.cut_lines = [] self.cut_extend = False - self.context_menu = [ - MenuItem('help', K.HELP, self.handle_help, None), - MenuItem('exit', K.QUIT, self.do_quit), - ] + + def run(self): + try: + self.scr = curses.initscr() + try: + self.writercls.init_colors(self.scr) + except Exception: + raise RuntimeError('it seems you have a terminal without colors. for a test, remove this line') + pass + curses.noecho() + curses.raw() # disable ctrl-C interrupt and ctrl-Q/S flow control + curses.nonl() # accept ctrl-J + self.scr.keypad(1) + KEY.init() + self.context_menu = [ + MenuItem('help', KEY.HELP, self.handle_help, None), + MenuItem('exit', KEY.QUIT, self.do_quit), + ] + while self.quit_action is None or not self.quit_action(): + key = self.handle(self) + if key not in (KEY.GOTO_MAIN, None, KEY.UP, KEY.DOWN, KEY.UNHANDLED): + self.status(f'unknown key {key}') + self.finish(None) + except Exception as e: + self.finish(e) + raise + finally: + if self.scr: + self.scr.keypad(0) + curses.echo() + curses.nocbreak() + curses.endwin() + for logline in self.log.loglines: + print(logline) def do_quit(self): - self.quit = True + self.quit_action = self.quit + return KEY.GOTO_MAIN + + def quit(self): + pass def get_topmargin(self): return sum(v.height() for v in self.headers) @@ -1098,46 +1286,40 @@ class Main(HasWidgets): key = self.scr.getch() else: continue - key = K.bynumber.get(key, key) + key = KEY.bynumber.get(key, key) self.status(None) if isinstance(key, str): - self.log.info('key letter %r', key) self.cut_extend = False return key if 0 <= key < 32: # control keys - self.log.info('key ctrl %r', key) - if key != K.CUT: + if key != KEY.CUT: self.cut_extend = False - self.log.info('extend False') - menu = ContextMenu(self.current_widget.get_menu()) - if key == K.MENU: - try: - if self.popupmenu: - return key - self.popupmenu = menu - key = menu.get_key(self) - if key == K.MENU: + menuitems = self.current_widget.get_menu() + if menuitems and not self.popupmenu: + menu = ContextMenu(menuitems) + if key == KEY.MENU: + try: + if self.popupmenu: + return key + self.popupmenu = menu + key = menu.get_key(self) + if key == KEY.MENU: + continue + if key in (KEY.TAB, KEY.RETURN, KEY.ENTER): + return menu.do_action() + key = menu.do_hotkey(key) + if key != KEY.UNHANDLED: + return key + finally: + self.popupmenu = None + result = menu.do_hotkey(key) + if result != KEY.UNHANDLED: + if result is None: continue - if key in (K.TAB, K.RETURN, K.ENTER): - return menu.do_action() - key = menu.do_hotkey(key) - if key != K.UNHANDLED: - self.log.info('got key %r', key) - return key - self.status(f'can not handle key {key}') - raise ValueError(menu.menuitems) - finally: - self.popupmenu = None - result = menu.do_hotkey(key) - if result != K.UNHANDLED: - if result is None: - continue + return result else: - if key != K.GOTO_MAIN: - if self.cut_extend: - self.log.info('extend False %r', key) + if key != KEY.GOTO_MAIN: self.cut_extend = False - self.log.info('key special %r', key) return key def handle_help(self): @@ -1145,10 +1327,10 @@ class Main(HasWidgets): self.popupmenu = None key = self.get_key() self.help_mode = False - return key if key == K.QUIT else None + return key if key == KEY.QUIT else None def persistent_status(self): - return 'ctrl-C: context menu/get help ctrl-X: exit' + return 'ctrl-X: context menu/help ctrl-Q: exit' def current_row(self): if self.help_mode: @@ -1216,34 +1398,4 @@ class Main(HasWidgets): self.statusbar.set(f'{text} {st}', warn) def finish(self, exc): - pass - - def run(self, writercls): - try: - self.scr = curses.initscr() - self.writercls = writercls - try: - writercls.init_colors(self.scr) - except Exception: - raise RuntimeError('it seems you have a terminal without colors. for a test, remove this line') - pass - curses.noecho() - curses.raw() # disable ctrl-C interrupt and ctrl-Q/S flow control - curses.nonl() # accept ctrl-J - self.scr.keypad(1) - while not self.quit: - key = self.handle(self) - if key not in (K.GOTO_MAIN, None, K.UP, K.DOWN): - self.status(f'unknown key {key}') - self.finish(None) - except Exception as e: - self.finish(e) - raise - finally: - if self.scr: - self.scr.keypad(0) - curses.echo() - curses.nocbreak() - curses.endwin() - for logline in self.log.loglines: - print(logline) + return True