diff --git a/bin/frappy-edit b/bin/frappy-edit index 7b96721c..d43631f4 100755 --- a/bin/frappy-edit +++ b/bin/frappy-edit @@ -30,7 +30,7 @@ repo = Path(__file__).absolute().parents[1] sys.path.insert(0, str(repo)) from frappy.lib import generalConfig -from frappy.tools.cfgedit import EditorMain +from frappy.editcurses.cfgedit import EditorMain # merge cfg dirs from env variable and the ones typically used at psi # use dicts instead of sets, as we want to keep order @@ -43,3 +43,4 @@ for cfgdir in 'cfg', 'cfg/main', 'cfg/stick', 'cfg/addons': os.environ['FRAPPY_CONFDIR'] = ':'.join(cfgdirs) generalConfig.init() EditorMain(sys.argv[1]).run() + diff --git a/frappy/editcurses/cfgedit.py b/frappy/editcurses/cfgedit.py index 23ef4c13..c0bd9833 100644 --- a/frappy/editcurses/cfgedit.py +++ b/frappy/editcurses/cfgedit.py @@ -4,14 +4,18 @@ import time from subprocess import Popen, PIPE from pathlib import Path from psutil import pid_exists +import frappy from frappy.errors import ConfigError from frappy.lib import generalConfig from frappy.lib import formatExtendedTraceback from frappy.config import 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 -import frappy.tools.terminalgui as tg -from frappy.tools.terminalgui import Main, MenuItem, TextEdit, PushButton, ModalDialog, KEY +from frappy.editcurses.configdata import Value, cfgdata_to_py, cfgdata_from_py, \ + get_datatype, site, ModuleClass, stringtype, class_completion, recommended_prs, \ + make_value, ModuleNameCompletion, Module +from frappy.io import IOBase +import frappy.editcurses.terminalgui as tg +from frappy.editcurses.terminalgui import Main, MenuItem, TextEdit, PushButton, \ + ModalDialog, KEY KEY.add( @@ -56,6 +60,13 @@ class StringValue: # TODO: unused? class TopWidget: parent_cls = Main + def handle(self, main): + key = super().handle(main) + if key == KEY.LEFT: + main.toggle_detailed() + return None + return key + class Child(tg.Widget): """child widget of NodeWidget ot ModuleWidget""" @@ -80,25 +91,33 @@ class HasValue(Child): def init_value_widget(self, parent, valobj): self.init_parent(parent) self.valobj = valobj + if isinstance(valobj.completion, ModuleNameCompletion): + valobj.completion.get_names = self.get_module_list 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 - valobj.datatype, valobj.error = get_datatype( - self.get_name(), self.clsobj, valobj.value) - valobj.validate_from_string(strvalue) + self.valobj = make_value(self.get_name(), self.clsobj, valobj.value) + if isinstance(self.valobj.completion, ModuleNameCompletion): + valobj.completion.get_names = self.get_module_list + self.valobj.validate_from_string(strvalue) self.error = None except Exception as e: self.error = str(e) - if main and (valobj.value, valobj.strvalue, valobj.error) != prev: + if main and valobj != self.valobj: main.touch() return valobj.strvalue + def get_module_list(self, basecls): + assert isinstance(self.valobj.completion, ModuleNameCompletion) + module = self.parent + main = module.parent + return main.get_module_list(basecls, module.get_name()) + def check_data(self): self.validate(self.valobj.strvalue) @@ -108,6 +127,7 @@ class HasValue(Child): class ValueWidget(HasValue, tg.LineEdit): fixedname = None + error = None def __init__(self, parent, name, valobj, label=None): """init a value widget @@ -132,7 +152,8 @@ class ValueWidget(HasValue, tg.LineEdit): def validate_name(self, name, main): widget_dict = self.parent.widget_dict - if name.isidentifier(): + pname, dot, prop = name.partition('.') + if pname.isidentifier() and (prop.isidentifier or dot == ''): other = widget_dict.get(name) if other and other != self: self.error = f'duplicate name {name!r}' @@ -158,6 +179,15 @@ class ValueWidget(HasValue, tg.LineEdit): if name: as_dict[name] = self.valobj + def draw(self, wr, in_focus=False): + super().draw(wr, in_focus) + valobj = self.valobj + if valobj.strvalue == '': + wr.dim(valobj.datatype.to_string(valobj.default)) + elif self.error: + wr.norm(' ') + wr.error(self.error) + class DocWidget(HasValue, tg.MultiLineEdit): parent_cls = TopWidget @@ -308,6 +338,11 @@ class ModuleWidget(BaseWidget): if clsobj: self.fixed_names.update({k: k for k, v in recommended_prs(clsobj).items() if v}) + def get_class(self): + clsvalue = self.widget_dict['cls'].valobj + clsvalue.set_from_string(clsvalue.strvalue) + return self.clsobj or Module + def new_widget(self, name='', pos=None): self.add_widget(name, Value('', *get_datatype('', self.clsobj, ''), from_string=True), self.focus) @@ -414,6 +449,9 @@ class IOWidget(ModuleWidget): 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 get_class(self): + return IOBase + def collect(self, result): name = self.get_name() if name: @@ -455,7 +493,7 @@ class EndLine(Child): key = main.get_key() if key in (KEY.RETURN, KEY.ENTER): self.parent.add_module(True) - elif key in (KEY.UP, KEY.DOWN, KEY.QUIT): + elif key in (KEY.LEFT, KEY.UP, KEY.DOWN, KEY.QUIT): return key elif key == 'i': self.parent.add_iomodule(True) @@ -609,43 +647,48 @@ class EditorMain(Main): cut_extend = False def __init__(self, cfg): - 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', 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', KEY.PREV_VERSION, self.prev_version), - ] - 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 - self.pidfile = None - self.dirty = False - # cleanup pidfiles - for file in self.version_dir.glob('*.pid'): - pidstr = file.read_text() - if not pid_exists(int(pidstr)): - file.unlink() - cfgpath = Path(cfg) - if cfg != cfgpath.stem: # this is a filename - if cfg.endswith('_cfg.py'): - cfg = cfgpath.name[:-7] + try: + self.titlebar = tg.TitleBar('Frappy Cfg Editor') + super().__init__([], tg.Writer, [self.titlebar], [tg.StatusBar(self)], + help_file=Path(frappy.__file__).parents[1] / 'resources/editcurses/help.txt') + # self.select_menu = MenuItem('select module', CUT_KEY) + self.version_menu = [ + 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', KEY.PREV_VERSION, self.prev_version), + ] + 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 + self.pidfile = None + self.dirty = False + # cleanup pidfiles + for file in self.version_dir.glob('*.pid'): + pidstr = file.read_text() + if not pid_exists(int(pidstr)): + file.unlink() + cfgpath = Path(cfg) + if cfg != cfgpath.stem: # this is a filename + if cfg.endswith('_cfg.py'): + cfg = cfgpath.name[:-7] + else: + cfg = self.cfgpath.stem else: - cfg = self.cfgpath.stem - else: - cfgpath = None - self.filecontent = None - self.set_node_name(cfg, cfgpath) - self.init_from_content(self.filecontent) - self.module_clipboard = {} - self.pr_clipboard = {} + cfgpath = None + self.filecontent = None + self.set_node_name(cfg, cfgpath) + self.modules = {} # dict of + self.init_from_content(self.filecontent) + self.module_clipboard = {} + self.pr_clipboard = {} + except Exception: + print(formatExtendedTraceback()) def get_menu(self): self.detailed_menuitem.name = 'summary view' if self.detailed else 'detailed view' @@ -671,6 +714,15 @@ class EditorMain(Main): self.widgets = widgets # self.dirty = False + def get_module_list(self, basecls, ownname): + """get module list for Attached. + + filter by basecls ad exclude own name + """ + return [w.get_name() for w in self.widgets + if w.get_name() != ownname and isinstance(w, ModuleWidget) + and issubclass(w.get_class(), basecls)] + def toggle_detailed(self): self.detailed = not self.detailed self.offset = None # recalculate offset from screen pos diff --git a/frappy/editcurses/configdata.py b/frappy/editcurses/configdata.py index f499ab86..427218c4 100644 --- a/frappy/editcurses/configdata.py +++ b/frappy/editcurses/configdata.py @@ -22,43 +22,39 @@ import re import frappy import inspect +import socket from pathlib import Path from ast import literal_eval from importlib import import_module from frappy.lib.comparestring import compare from frappy.config import process_file, Node -from frappy.core import Module, Parameter, Property +from frappy.core import Module, Parameter, Property, Attached from frappy.datatypes import DataType, EnumType +from frappy.properties import Property, UNSET -HEADER = "# please edit this file with frappy edit" +HEADER = "# please edit this file with bin/frappy-edit" class Site: - domain = 'psi.ch' - frappy_subdir = 'frappy_psi' base = Path(frappy.__file__).parent.parent + default_interface = 'tcp://10767' + domain = None + frappy_subdir = None + equipment_postfix = 'your.postfix' - def __init__(self, domain='psi.ch', frappy_subdir='frappy_psi', default_interface='tcp://10767'): - self.init(domain, frappy_subdir, default_interface) + # for individual sites - def init(self, domain=None, frappy_subdir=None, default_interface=None): - if domain: - self.domain = domain - if default_interface: - self.default_interface = default_interface - if frappy_subdir: - self.packages = [v.name for v in self.base.glob('frappy_*')] - try: # psi should be first - self.packages.remove(frappy_subdir) - self.packages.insert(0, frappy_subdir) + def __init__(self): + self.packages = [v.name for v in self.base.glob('frappy_*')] + if self.frappy_subdir: + try: # own subdir should be first + self.packages.remove(self.frappy_subdir) + self.packages.insert(0, self.frappy_subdir) except ValueError: pass -site = Site() - - class NonStringType: """any type except string""" def validate(self, value): @@ -67,7 +63,6 @@ class NonStringType: def __call__(self, value): return value - def from_string(self, strvalue): """convert from string """ try: @@ -129,15 +124,26 @@ class Value: strvalue = None modulecls = None datatype = None + default = None value = None completion = None - def __init__(self, value, datatype=None, error=None, from_string=False, callback=None): + def __init__(self, value, datatype=None, error=None, pr=None, + from_string=False, callback=None): if value is None: raise ValueError(datatype) self.datatype = datatype + if isinstance(pr, Property): + if pr.value is UNSET: + self.default = None if pr.default is UNSET else pr.default + else: + self.default = pr.value + elif isinstance(pr, Parameter): + self.default = pr.default if pr.value is None else pr.value if isinstance(datatype, EnumType): self.completion = NameCompletion([v.name for v in datatype._enum.members]) + elif isinstance(pr, Attached): + self.completion = ModuleNameCompletion(pr.basecls) self.error = error if callback: self.callback = callback @@ -193,6 +199,10 @@ class Value: pass return repr(self.strvalue) + def __eq__(self, other): + return (self.value, self.error, self.strvalue + ) == (other.value, other.error, other.strvalue) + def __repr__(self): return f'{type(self).__name__}({self.value!r}, {self.datatype!r})' @@ -203,7 +213,7 @@ def get_datatype(pname, cls, value): :param pname: or or . :param cls: a frappy Module class or None :param value: the given value (needed only in case the datatype can not be determined) - :return: + :return: datatype, error or None, property or parameter or None """ param, _, prop = pname.partition('.') error = None @@ -215,18 +225,19 @@ def get_datatype(pname, cls, value): if propobj is None: error = f'{cls.__module__}.{cls.__qualname__}.{param}.{prop} is not configurable' else: - return propobj.datatype, None + return propobj.datatype, None, propobj elif prop: error = f'{cls.__module__}.{cls.__qualname__}.{param} is not a parameter' else: - return prop_param.datatype, None + return prop_param.datatype, None, prop_param + # return prop_param.datatype, None, None except AttributeError: error = f'{cls.__module__}.{cls.__qualname__} is not a Frappy Module' except KeyError: error = f'{cls.__module__}.{cls.__qualname__}.{param} is not configurable' if isinstance(value, str): - return stringtype, error - return nonstringtype, error + return stringtype, error, None + return nonstringtype, error, None def make_value(pname, cls, value): @@ -394,7 +405,7 @@ def fix_equipment_id(name, equipment_id): """normalize equipment id""" if re.match(r'[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$', equipment_id): return equipment_id - return f'{name}.{site.domain}' + return f'{name}.{site.equipment_postfix}' def fix_node_class(cls): @@ -603,11 +614,35 @@ def class_completion(value): class NameCompletion: def __init__(self, names): self.names = names - self.nameset = set(names) def __call__(self, value): - if value in self.nameset: + if value in self.names: return len(value), [] return 0, [value] + list(get_suggested(value, self.names)) +class ModuleNameCompletion: + def __init__(self, basecls): + self.basecls = basecls + + def get_names(self, basecls): + return [] + + def __call__(self, value): + names = self.get_names(self.basecls) + if value in names: + return len(value), [] + return 0, [value] + list(get_suggested(value, names)) + + +class SitePSI(Site): + domain = '.psi.ch' + frappy_subdir = 'frappy_psi' + equipment_postfix = 'psi.ch' + + +hostname = socket.getfqdn() +for sitecls in [SitePSI]: # add here other sites + if hostname.endswith(sitecls.domain): + break +site = sitecls() diff --git a/frappy/editcurses/terminalgui.py b/frappy/editcurses/terminalgui.py index 88d29e55..09e7f319 100644 --- a/frappy/editcurses/terminalgui.py +++ b/frappy/editcurses/terminalgui.py @@ -100,6 +100,7 @@ class Key(int): return self.name +# keep this file independent of frappy def clamp(*args): return sorted(args)[len(args) // 2] @@ -303,6 +304,7 @@ class TextEdit(Widget): self.pos = None # cursor pos or None when not editing if value is None: raise ValueError('textedit') + assert isinstance(value, str) self.value = value self.col_offset = 0 self.finish_callback = finish_callback @@ -382,15 +384,6 @@ class TextEdit(Widget): self.value = self.finish(self.value, main) else: self.value = self.prev_value - # if save: - # try: - # self.value = self.validator(self.value) - # self.error = None - # except Exception as e: - # self.error = str(e) - # # main.touch() - # else: - # self.value = self.prev_value self.pos = None @@ -415,6 +408,7 @@ class Completion: class TextEditCompl(TextEdit): menu = None + exc_info = None def __init__(self, value, finish_callback, completion): super().__init__(value, finish_callback) @@ -425,25 +419,29 @@ class TextEditCompl(TextEdit): main.popupmenu = None def get_selection_menu(self, main, value): - self.completion_pos, selection = self.completion(value) - if (self.pos >= self.completion_pos and - self == main.completion_widget): # widget has not changed - if selection: - main.popup_offset = self.completion_pos - self.pos - main.popupmenu = CompletionMenu(selection) - else: - main.popupmenu = None - self.completion_widget = None + self.exc_info = None + try: + self.completion_pos, selection = self.completion(value) + if (self.pos >= self.completion_pos and + self == main.completion_widget): # widget has not changed + if selection: + main.popup_offset = self.completion_pos - self.pos + main.popupmenu = CompletionMenu(selection) + else: + main.popupmenu = None + self.completion_widget = None + except Exception: + self.exc_info = sys.exc_info() def get_key(self, main): - # if self.completion is None: - # return super().get_key(main) if self.highlighted: return super().get_key(main) main.completion_widget = self - mkthread(self.get_selection_menu, main, self.value) + self.selection_thread = mkthread(self.get_selection_menu, main, self.value) # self.get_selection_menu(main, self.value) key = super().get_key(main) + if self.exc_info: + raise self.exc_info[1].with_traceback(self.exc_info[2]) self.menu = menu = main.popupmenu if not menu or self.highlighted or key != KEY.DOWN or self.pos < self.completion_pos: self.menu = main.popupmenu = None @@ -470,8 +468,6 @@ class TextEditCompl(TextEdit): class TextWidget(Widget): - # minwidth = 16 - def __init__(self, text): self.value = text @@ -483,8 +479,6 @@ class TextWidget(Widget): class LineEdit(Widget): - error = None - def __init__(self, labelwidget, valuewidget): self.labelwidget = labelwidget self.valuewidget = valuewidget @@ -503,7 +497,7 @@ class LineEdit(Widget): if key == KEY.LEFT: if name is not None: self.focus = 0 - continue + continue return key key = self.labelwidget.handle(main) self.focus = 1 @@ -517,9 +511,6 @@ class LineEdit(Widget): wr.norm(': ') wr.col = max(wr.col, wr.left + wr.leftwidth) self.valuewidget.draw(wr, in_focus and self.focus == 1) - if self.error: - wr.norm(' ') - wr.error(self.error) class MultiLineEdit(Container): @@ -564,6 +555,9 @@ class MultiLineEdit(Container): self.highlighted = True key = main.get_key() self.highlighted = False + return self.handle_inner(main, key) + + def handle_inner(self, main, key, focus=None): if key in (KEY.RIGHT, KEY.TAB): self.set_focus(None, -1) self.next_key = KEY.RIGHT @@ -580,6 +574,8 @@ class MultiLineEdit(Container): elif key == KEY.LEFT: self.set_focus(None, 0) self.next_key = KEY.LEFT + elif focus is not None: + self.set_focus(focus, 0) else: return key while True: @@ -596,6 +592,65 @@ class MultiLineEdit(Container): return key +class HelpWidget(MultiLineEdit): + winsize = 25 + + def __init__(self, main): + self.init_parent(main) + if main.help_file: + text = main.help_file.read_text() + else: + text = main.help_text + self.original = text + self.readonly = True + self.readonly_menu = [ + MenuItem('edit help text', KEY.END_LINE), + MenuItem('quit help', '^q'), + ] + self.edit_menu = [ + MenuItem('quit help', '^q'), + ] + super().__init__('', text, '') + + def draw(self, wr, in_focus=False): + wr.leftwidth = 0 + self.winsize = wr.height + super().draw(wr, True) + + def get_menu(self): + if self.readonly: + return self.readonly_menu + return self.cut_menuitems + self.edit_menu + + def handle(self, main): + self.focus = 0 + main.offset = 0 + height = len(self.widgets) + step = self.winsize // 2 + if self.readonly: + while True: + key = main.get_key() + if key == KEY.UP: + main.offset = max(0, main.offset - step) + elif key == KEY.DOWN: + main.offset = max(0, main.offset + step) + else: + if key != KEY.END_LINE: + return + self.readonly = False + break + self.focus = clamp(0, main.offset + step, height) + try: + super().handle_inner(main, None, self.focus) + return + finally: + if self.original != self.value: + main.popupmenu = menu = ConfirmDialog('save changes to help text?') + if menu.handle(main): + main.help_file.write_text(self.value) + main.status(f'{main.help_file} changed') + + class LineOfMultiline(TextEdit): minwidth = -1 change_high = None @@ -739,9 +794,6 @@ class DialogInput(Widget): 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) @@ -789,10 +841,6 @@ class PushButton(Widget): 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 @@ -900,7 +948,6 @@ class ContextMenu(PopUpMenu): """ self.menuitems = [MenuItem('')] + menuitems self.height = len(self.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): @@ -935,7 +982,7 @@ class ContextMenu(PopUpMenu): class BaseWriter: """base for writer. does nothing else than keeping track of position""" - highstyle = brightstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = buttonstyle = None + highstyle = brightstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = buttonstyle = dimstyle = None errorflag = '! ' def __init__(self): @@ -955,6 +1002,9 @@ class BaseWriter: def norm(self, text, width=0): self.wr(text, extend_to=width) + def dim(self, text, width=0): + self.wr(text, self.dimstyle, extend_to=width) + def bar(self, text, width=0): self.wr(text, self.barstyle, extend_to=width) @@ -1011,6 +1061,7 @@ class Writer(BaseWriter): newoffset = None querystyle = curses.A_BOLD warnstyle = curses.A_REVERSE + dimstyle = 0 pairs = {} colors = {} nextcolor = 16 @@ -1094,6 +1145,8 @@ class Writer(BaseWriter): cls.querystyle = cls.make_pair(black, light_green) yellow = 1000, 1000, 0 cls.warnstyle = cls.make_pair(black, yellow) + grey = 400, 400, 400 + cls.dimstyle = cls.make_pair(grey, dim_white) def edit(self, text, width, pos): """write text and set cursor at given pos @@ -1214,11 +1267,7 @@ class Main(HasWidgets): log = Widget.log leftwidth = 0.2 # if < 1: a fraction - 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) + def __init__(self, widgets, writercls, headers=(), footers=(), help_file=None): self.writercls = writercls self.focus = 0 self.headers = headers @@ -1237,6 +1286,7 @@ class Main(HasWidgets): self.quit_action = None self.cut_lines = [] self.cut_extend = False + self.help_file = help_file def run(self): try: @@ -1252,7 +1302,7 @@ class Main(HasWidgets): self.scr.keypad(True) KEY.init() self.context_menu = [ - MenuItem('help', KEY.HELP, self.handle_help, None), + MenuItem('help', KEY.HELP, self.handle_help, KEY.GOTO_MAIN), MenuItem('exit', KEY.QUIT, self.do_quit), ] while self.quit_action is None or not self.quit_action(): @@ -1332,20 +1382,30 @@ class Main(HasWidgets): self.cut_extend = False return key + def handle(self, main): + if self.help_mode: + widgets = self.widgets + try: + help_widget = HelpWidget(self,) + self.widgets = [help_widget] + key = super().handle(main) + return key + finally: + self.widgets = widgets + self.help_mode = False + else: + return super().handle(main) + def handle_help(self): self.help_mode = True self.popupmenu = None - key = self.get_key() - self.help_mode = False - return key if key == KEY.QUIT else None def persistent_status(self): return 'ctrl-X: context menu/help ctrl-Q: exit' def current_row(self): if self.help_mode: - # TODO: scroll help - return 0 + return self.widgets[0].current_row() return super().current_row() def refresh(self): @@ -1364,7 +1424,7 @@ class Main(HasWidgets): bot_limit = max(self.screen_row, int(end_scroll * 0.7)) top_limit = min(self.screen_row, int(end_scroll * 0.2) + 1, bot_limit) if self.offset is None: - self.offset = current_row - self.screen_row + self.offset = current_row - max(topmargin, self.screen_row) offset = clamp(self.offset, current_row - top_limit, current_row - bot_limit) self.offset = clamp(offset, 0, offsetlimit) self.screen_row = current_row - self.offset @@ -1380,18 +1440,12 @@ class Main(HasWidgets): widget.draw(wr) wr.offset = self.offset wr.top, wr.bot = topmargin, bot - if self.help_mode: - for line in self.help_text.split('\n'): - wr.startrow() - wr.norm(line) - else: - self.draw_widgets(wr, True) + self.draw_widgets(wr, True) wr.top, wr.bot = 0, wr.height if self.popupmenu: col = self.popup_offset col += self.cursor_pos[1] wr.move(current_row, col) - # wr.move(*self.popupmenu.row_col) self.popupmenu.draw(wr) if wr.cursor_visible: self.scr.move(*self.cursor_pos) diff --git a/frappy/lib/comparestring.py b/frappy/lib/comparestring.py new file mode 100644 index 00000000..e2c9296f --- /dev/null +++ b/frappy/lib/comparestring.py @@ -0,0 +1,150 @@ +# +# (C) 2005, Rob W. W. Hooft (rob@hooft.net) +# +# Comparison algorithm as implemented by Reinhard Schneider and Chris Sander +# for comparison of protein sequences, but implemented to compare two +# ASCII strings. This can be very useful for command interpreters to account +# for mistyped commands (use the routine "compare(s1, s2)" in here to get +# a score for each possible command, and see if one stands out). The comparison +# makes use of a similarity matrix for letters: in the protein case this is +# normally a chemical functionality similarity, for our case this is a matrix +# based on keys next to each other on a US Qwerty keyboard and on "capital +# letters are similar to their lowercase equivalent" +# +# The algorithm does not cut corners: it is sure to find the absolute best +# match between the two strings. +# +# No attempt has been made to make this efficient in time or memory. Time taken +# and memory used is proportional to the product of the length of the input +# strings. Use for strings longer than 25 characters is entirely for your own +# risk. +# +# Use freely, but please attribute when using. +# from http://starship.python.net/crew/hooft/ + +# How much does it cost to make a hole in one of the strings? +GAPOPENPENALTY = -0.3 +# How much does it cost to elongate a hole in one of the strings? +GAPELONGATIONPENALTY = -0.2 +# How much alike (0.0-1.0) are small and capital letters? +CAPITALIZESCORE = 0.8 +# How much alike (0.0-1.0) are characters next to each other on a (US) keyboard? +NEXTKEYSCORE = 0.6 + +comparematrix = {} + + +def _makekeyboardmap(): + # different characters score 0.0, equal characters score 1.0 + for i in range(33, 126 + 1): + for j in range(33, 126 + 1): + comparematrix[i, j] = 0.0 + comparematrix[i, i] = 1.0 + # Capital and small letters are CAPITALIZESCORE alike + capdist = ord('A') - ord('a') + for i in range(ord('a'), ord('z') + 1): + comparematrix[i, i + capdist] = CAPITALIZESCORE + comparematrix[i + capdist, i] = CAPITALIZESCORE + + # Keyboard layout, add some score for letters that are close together + line1 = '`1234567890-= ' + line2 = ' qwertyuiop[] ' + line3 = ' asdfghjkl; ' + line4 = ' zxcvbnm,./ ' + for i in range(len(line1) - 1): + _keyboardneighbour(line1[i], line1[i + 1]) + _keyboardneighbour(line2[i], line2[i + 1]) + _keyboardneighbour(line3[i], line3[i + 1]) + _keyboardneighbour(line4[i], line4[i + 1]) + _keyboardneighbour(line1[i], line2[i]) + _keyboardneighbour(line2[i], line3[i]) + _keyboardneighbour(line3[i], line4[i]) + _keyboardneighbour(line1[i], line2[i + 1]) + _keyboardneighbour(line2[i], line3[i + 1]) + _keyboardneighbour(line3[i], line4[i + 1]) + + +def _keyboardneighbour(c1, c2): + i1 = ord(c1) + i2 = ord(c2) + if 33 <= i1 <= 126 and 33 <= i2 <= 126: + comparematrix[i1, i2] = NEXTKEYSCORE + comparematrix[i2, i1] = NEXTKEYSCORE + +_makekeyboardmap() + + +def compare(s1, s2): + lh = {} + gapped = {} + + l1 = len(s1) + l2 = len(s2) + + if s1 == s2: + return l1 + 1 + + # Top left of the matrix is "before the first character" in both directions + lh[1, 1] = 0.0 + gapped[1, 1] = False + + # Start with a gap in s1 + lh[2, 1] = GAPOPENPENALTY + gapped[2, 1] = True + + for ii in range(3, l1 + 2): + lh[ii, 1] = lh[ii - 1, 1] + GAPELONGATIONPENALTY + gapped[ii, 1] = True + + # Start with a gap in s2 + lh[1, 2] = GAPOPENPENALTY + gapped[1, 2] = True + + for jj in range(3, l2 + 2): + lh[1, jj] = lh[1, jj - 1] + GAPELONGATIONPENALTY + gapped[1, jj] = True + + # The main algorithm: for each point in the matrix decide what the best + # route so far is, by comparing the diagonal route forward with the + # possibility to open or elongate a gap either way. + for jj in range(1, l2 + 1): + for ii in range(1, l1 + 1): + oc1 = ord(s1[ii - 1]) + oc2 = ord(s2[jj - 1]) + if 33 <= oc1 <= 126 and 33 <= oc2 <= 126: + ld = comparematrix[oc1, oc2] + elif oc1 == oc2: + ld = 1.0 + else: + ld = 0.0 + + if gapped[ii + 1, jj]: + gph = GAPELONGATIONPENALTY + else: + gph = GAPOPENPENALTY + + if gapped[ii, jj + 1]: + gpv = GAPELONGATIONPENALTY + else: + gpv = GAPOPENPENALTY + + s = lh[ii, jj] + ld + sh = lh[ii + 1, jj] + gph + sv = lh[ii, jj + 1] + gpv + sd = max(sh, sv) + if s >= sd: + lh[ii + 1, jj + 1] = s + gapped[ii + 1, jj + 1] = False + else: + lh[ii + 1, jj + 1] = sd + gapped[ii + 1, jj + 1] = True + # The highest alignment score is in the bottom right corner of the matrix, + # behind the last character in both strings + return lh[l1 + 1, l2 + 1] + + +def test(): + assert compare('test', 'test') == len('test') + 1.0 + assert compare('Test', 'test') == len('test') - 1.0 + CAPITALIZESCORE + assert compare('rest', 'test') == len('test') - 1.0 + NEXTKEYSCORE + assert compare('rest', 'crest') == len('rest') + GAPOPENPENALTY