beta version of frappy-edit

Change-Id: I82b35505207429cddac44d28222e20627b3a90b3
This commit is contained in:
2026-02-11 13:36:44 +01:00
parent 53256d1583
commit e1e642fb2f
3 changed files with 700 additions and 392 deletions

View File

@@ -4,31 +4,31 @@ import time
from subprocess import Popen, PIPE from subprocess import Popen, PIPE
from pathlib import Path from pathlib import Path
from psutil import pid_exists from psutil import pid_exists
from frappy.core import Module
from frappy.errors import ConfigError from frappy.errors import ConfigError
from frappy.lib import generalConfig from frappy.lib import generalConfig
from frappy.lib.comparestring import compare
from frappy.lib import mkthread, formatExtendedTraceback from frappy.lib import mkthread, formatExtendedTraceback
from frappy.config import process_file, to_config_path 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.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.completion import class_completion, recommended_prs
from frappy.tools.terminalgui import Main, Container, LineEdit, MultiLineEdit, MenuItem, Writer, ContextMenu, \ import frappy.tools.terminalgui as tg
TextWidget, TextEdit, NameEdit, TextEditCompl, Completion, TitleBar, StatusBar, Widget, ConfirmDialog, \ from frappy.tools.terminalgui import Main, MenuItem, TextEdit, PushButton, ModalDialog, KEY
K
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''''" VERSION_SEPARATOR = "\n''''"
TIMESTAMP_FMT = '%Y-%m-%d-%H%M%S' TIMESTAMP_FMT = '%Y-%m-%d-%H%M%S'
# TODO: # TODO:
# - ctrl-K / ctrlV/U for cutting/pasting module(s) or parameter(2) # - ctrl-K: on previous versions copy and advance to next module
# * 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
# - use also shift-Tab for level up? # - use also shift-Tab for level up?
# - directory to save to # - directory to save to
# - remove older versions, for which a newer exist already: a restore removes the older one
def unix_cmd(cmd, *args): def unix_cmd(cmd, *args):
out = Popen(cmd.split() + list(args), stdout=PIPE).communicate()[0] out = Popen(cmd.split() + list(args), stdout=PIPE).communicate()[0]
@@ -55,7 +55,7 @@ class TopWidget:
parent_cls = Main parent_cls = Main
class Child(Widget): class Child(tg.Widget):
"""child widget of NodeWidget ot ModuleWidget""" """child widget of NodeWidget ot ModuleWidget"""
parent = TopWidget parent = TopWidget
@@ -81,23 +81,21 @@ class HasValue(Child):
def validate(self, strvalue, main=None): def validate(self, strvalue, main=None):
pname = self.get_name() pname = self.get_name()
valobj = self.valobj
prev = valobj.value, valobj.strvalue, valobj.error
try: try:
if pname != 'cls': if pname != 'cls':
if self.clsobj != self.parent.clsobj: if self.clsobj != self.parent.clsobj:
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) valobj.datatype, valobj.error = get_datatype(
self.valobj.datatype, self.valobj.error = get_datatype( self.get_name(), self.clsobj, valobj.value)
self.get_name(), self.clsobj, self.valobj.value) valobj.validate_from_string(strvalue)
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)
self.error = None self.error = None
except Exception as e: except Exception as e:
self.error = str(e) self.error = str(e)
if self.get_name() == 'tolerance': if main and (valobj.value, valobj.strvalue, valobj.error) != prev:
self.log.info('checked %r %r', self.valobj, self.valobj.error)
if main:
main.touch() main.touch()
return strvalue return valobj.strvalue
def check_data(self): def check_data(self):
self.validate(self.valobj.strvalue) self.validate(self.valobj.strvalue)
@@ -106,7 +104,7 @@ class HasValue(Child):
return self.get_name() and self.valobj.strvalue return self.get_name() and self.valobj.strvalue
class ValueWidget(HasValue, LineEdit): class ValueWidget(HasValue, tg.LineEdit):
fixedname = None fixedname = None
def __init__(self, parent, name, valobj, label=None): def __init__(self, parent, name, valobj, label=None):
@@ -119,13 +117,13 @@ class ValueWidget(HasValue, LineEdit):
""" """
self.init_value_widget(parent, valobj) self.init_value_widget(parent, valobj)
if label is not None: if label is not None:
labelwidget = TextWidget(label) labelwidget = tg.TextWidget(label)
self.fixedname = name self.fixedname = name
else: else:
labelwidget = NameEdit(name, self.validate_name) labelwidget = tg.NameEdit(name, self.validate_name)
# self.log.info('value widget %r %r', name, self.fixedname) # self.log.info('value widget %r %r', name, self.fixedname)
if valobj.completion: if valobj.completion:
valueedit = TextEditCompl(valobj.strvalue, self.validate, valobj.completion) valueedit = tg.TextEditCompl(valobj.strvalue, self.validate, valobj.completion)
else: else:
valueedit = TextEdit(valobj.strvalue, self.validate) valueedit = TextEdit(valobj.strvalue, self.validate)
super().__init__(labelwidget, valueedit) super().__init__(labelwidget, valueedit)
@@ -159,7 +157,7 @@ class ValueWidget(HasValue, LineEdit):
as_dict[name] = self.valobj as_dict[name] = self.valobj
class DocWidget(HasValue, MultiLineEdit): class DocWidget(HasValue, tg.MultiLineEdit):
parent_cls = TopWidget parent_cls = TopWidget
def __init__(self, parent, name, valobj): def __init__(self, parent, name, valobj):
@@ -176,11 +174,12 @@ class DocWidget(HasValue, MultiLineEdit):
config[self.name] = self.valobj config[self.name] = self.valobj
class BaseWidget(TopWidget, Container): class BaseWidget(TopWidget, tg.Container):
"""base for Module or Node""" """base for Module or Node"""
clsobj = None clsobj = None
header = 'Module' header = None
special_names = 'name', 'cls', 'description' special_names = 'name', 'cls', 'description'
endline_help = 'RET: add module p: add property'
def init(self, parent): def init(self, parent):
self.widgets = [] self.widgets = []
@@ -216,16 +215,22 @@ class BaseWidget(TopWidget, Container):
def new_widget(self): def new_widget(self):
raise NotImplementedError raise NotImplementedError
def add_module(self, after_current=False): def insert_module(self, module, after_current=False):
modcfg = {'name': Value(''), 'cls': Value(f'{site.frappy_subdir}.'), 'description': Value('')}
main = self.parent main = self.parent
module = ModuleWidget(main, '', modcfg)
main.insert(main.focus + after_current, module) main.insert(main.focus + after_current, module)
main.set_focus(main.focus + 1) main.set_focus(main.focus + 1)
if not after_current: if not after_current:
self.set_focus(1) self.set_focus(1)
main.advance(-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): def get_widget_value(self, key):
try: try:
@@ -248,14 +253,6 @@ class BaseWidget(TopWidget, Container):
else: else:
self.draw_summary(wr, in_focus) 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): def collect(self, result):
name = self.get_name() name = self.get_name()
if name: if name:
@@ -280,6 +277,9 @@ class ModuleName(Value):
class ModuleWidget(BaseWidget): 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): def __init__(self, parent, name, modulecfg):
assert name == modulecfg['name'].value assert name == modulecfg['name'].value
modulecfg['name'] = ModuleName(parent, name) modulecfg['name'] = ModuleName(parent, name)
@@ -287,20 +287,24 @@ class ModuleWidget(BaseWidget):
self.context_menu = [ self.context_menu = [
MenuItem('add parameter/property', 'p', self.new_widget), MenuItem('add parameter/property', 'p', self.new_widget),
MenuItem('add module', 'm', self.add_module), MenuItem('add module', 'm', self.add_module),
MenuItem('add io module', 'i', self.add_iomodule),
MenuItem('purge empty prs', 'e', self.purge_prs), MenuItem('purge empty prs', 'e', self.purge_prs),
MenuItem('add recommended prs', '+', self.complete_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.callback = self.update_cls
clsvalue.completion = class_completion clsvalue.completion = class_completion
clsobj = clsvalue.value clsobj = clsvalue.value
if clsobj: if clsobj:
self.fixed_names.update({k: k for k, v in recommended_prs(clsobj).items() if v}) 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): def new_widget(self, name='', pos=None):
self.add_widget(name, Value('', *get_datatype('', self.clsobj, ''), from_string=True), self.focus) 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): def handle(self, main):
while True: while True:
key = super().handle(main) if main.detailed else main.get_key() 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.detailed = True
main.status('') main.status('')
main.offset = None # recalculate offset from screen pos main.offset = None # recalculate offset from screen pos
else: else:
return key 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): def check_data(self):
"""check clsobj is valid and check all params and props""" """check clsobj is valid and check all params and props"""
# clswidget, = self.find_widgets('cls') # 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.widgets = [w for w in self.widgets if w.get_name() not in self.fixed_names and w.is_valid()]
self.update_widget_dict() 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): def draw_summary(self, wr, in_focus):
wr.startrow() wr.startrow()
wr.norm('Module ') wr.norm(self.header.ljust(7))
name = self.get_widget_value('name') name = self.get_widget_value('name')
if in_focus: if in_focus:
wr.set_cursor_pos() wr.set_cursor_pos()
wr.bright(name, round(wr.width * 0.2)) wr.bright(name, round(wr.width * 0.2))
else: else:
wr.norm(name.ljust(round(wr.width * 0.2))) wr.norm(name.ljust(round(wr.width * 0.2)))
half = (wr.width - wr.col) // 2 self.draw_summary_right(wr)
wr.norm(f"{self.get_widget_value('description').ljust(half)} {self.get_widget_value('cls')} ")
def collect(self, result): def collect(self, result):
super().collect(result) super().collect(result)
@@ -375,9 +390,42 @@ class ModuleWidget(BaseWidget):
assert result[name].pop('name').value == name 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('<auto>', stringtype)
modcfg.setdefault('description', Value('', stringtype))
def configure_class(self, clsvalue):
pass
class EndLine(Child):
parent_cls = TopWidget parent_cls = TopWidget
helptext = 'RET: add module p: add parameter or property'
def __init__(self, parent): def __init__(self, parent):
self.init_parent(parent) self.init_parent(parent)
@@ -388,8 +436,7 @@ class EndNode(Child):
if in_focus: if in_focus:
wr.set_cursor_pos(wr.leftwidth) wr.set_cursor_pos(wr.leftwidth)
wr.col = wr.leftwidth wr.col = wr.leftwidth
wr.bright(' ') wr.high(self.parent.endline_help)
wr.norm(' ' + self.helptext)
def collect(self, result): def collect(self, result):
pass pass
@@ -404,52 +451,29 @@ class EndNode(Child):
self.showhelp = False self.showhelp = False
while True: while True:
key = main.get_key() key = main.get_key()
if key in (K.RETURN, K.ENTER): if key in (KEY.RETURN, KEY.ENTER):
self.parent.add_module(True) 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 return key
elif key == 'i':
self.parent.add_iomodule(True)
elif key == 'p': elif key == 'p':
self.parent.new_widget() self.parent.new_widget()
return K.GOTO_MAIN return KEY.GOTO_MAIN
return None 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): class NodeName(Value):
def __init__(self, main, name): def __init__(self, main, name):
self.main = main self.main = main
super().__init__(name) super().__init__(name)
def set_from_string(self, value): def validate_from_string(self, value):
self.error = self.main.set_node_name(value) try:
if self.error: self.main.set_node_name(value)
except Exception:
self.value = self.strvalue = self.main.cfgname self.value = self.strvalue = self.main.cfgname
else: raise
self.value = self.strvalue = value
class NodeWidget(BaseWidget): class NodeWidget(BaseWidget):
@@ -471,7 +495,7 @@ class NodeWidget(BaseWidget):
self.widget_dict['doc'] = docwidget self.widget_dict['doc'] = docwidget
else: else:
self.add_widget(name, valobj) self.add_widget(name, valobj)
self.widgets.append(EndNode(self)) self.widgets.append(EndLine(self))
def new_widget(self, name=''): def new_widget(self, name=''):
"""insert new widget at focus pos""" """insert new widget at focus pos"""
@@ -481,43 +505,23 @@ class NodeWidget(BaseWidget):
return 'node' return 'node'
def set_focus(self, focus, step=1): 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): 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: if self.parent.detailed or self.get_focus_widget().get_name() in self.summ_edit:
self.log.info('found')
return True return True
focus = self.focus + step 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 main = self.parent
return sum(w.height() for w in self.widgets if main.detailed or w.get_name() in self.summ_edit) if not main.detailed:
return super().height(to_focus)
height = 0
# def handle(self, main): if to_focus is None:
# if main.detailed: to_focus = len(self.widgets)
# return super().handle(main) for nr, widget in enumerate(self.widgets[:to_focus]):
# advance = self.next name = widget.get_name()
# key = None if name in self.summ_edit:
# itry = 10 height += widget.height()
# while itry > 0: return height
# 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')
def draw_summary(self, wr, in_focus): def draw_summary(self, wr, in_focus):
# wr.startrow() # wr.startrow()
@@ -530,9 +534,66 @@ class NodeWidget(BaseWidget):
widget.draw(wr, nr == focus) 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 = """ HELP_TEXT = """
ctrl-X Exit Frappy Configuration Editor
ctrl-T Toggle view mode (summary <-> detailed) ---------------------------
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-<key>
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 cut_extend = False
def __init__(self, cfg): def __init__(self, cfg):
self.titlebar = TitleBar('Frappy Cfg Editor') self.titlebar = tg.TitleBar('Frappy Cfg Editor')
super().__init__([], [self.titlebar], [StatusBar(self)]) super().__init__([], tg.Writer, [self.titlebar], [tg.StatusBar(self)])
# self.select_menu = MenuItem('select module', CUT_KEY) # self.select_menu = MenuItem('select module', CUT_KEY)
self.version_menu = [ self.version_menu = [
MenuItem('previous version', K.PREV_VERSION, self.prev_version), MenuItem('previous version', KEY.PREV_VERSION, self.prev_version),
MenuItem('next version', K.NEXT_VERSION, self.next_version), MenuItem('next version', KEY.NEXT_VERSION, self.next_version),
MenuItem('restore this version', 'r', self.restore_version), MenuItem('restore this version', 'r', self.restore_version),
MenuItem('copy module', KEY.CUT, self.cut_module),
] ]
self.main_menu = [ 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.detailed_menuitem = MenuItem('toggle detailed', KEY.TOGGLE_DETAILED, self.toggle_detailed)
self.cut_menuitem = MenuItem('insert cut modules', K.PASTE, self.insert_cut) 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 = Path('~/.local/share/frappy_config_editor').expanduser()
self.version_dir.mkdir(mode=0o700, parents=True, exist_ok=True) self.version_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
self.cfgname = None self.cfgname = None
@@ -580,35 +642,34 @@ class EditorMain(Main):
else: else:
cfgpath = None cfgpath = None
self.filecontent = None self.filecontent = None
error = self.set_node_name(cfg, cfgpath) self.set_node_name(cfg, cfgpath)
if error:
raise RuntimeError(error)
self.init_from_content(self.filecontent) self.init_from_content(self.filecontent)
self.module_clipboard = {} self.module_clipboard = {}
self.pr_clipboard = {} self.pr_clipboard = {}
def get_menu(self): def get_menu(self):
self.detailed_menuitem.name = 'summary view' if self.detailed else 'detailed view' self.detailed_menuitem.name = 'summary view' if self.detailed else 'detailed view'
cmenu = [self.cut_menuitem] if self.cut_modules else [] menu = self.version_menu if self.version_view else self.main_menu
vmenu = self.version_menu if self.version_view else self.main_menu if self.cut_modules and not self.version_view:
return cmenu + vmenu + [self.detailed_menuitem] + self.context_menu 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): def init_from_content(self, filecontent):
self.log.info('init from content %r', len(filecontent))
widgets = [] widgets = []
nodedata, moddata = cfgdata_from_py(self.cfgname, self.cfgpath, filecontent, self.log) nodedata, moddata = cfgdata_from_py(self.cfgname, self.cfgpath, filecontent, self.log)
widgets.append(NodeWidget(self, self.cfgname, nodedata)) widgets.append(NodeWidget(self, self.cfgname, nodedata))
for key, modcfg in moddata.items(): for key, modcfg in moddata.items():
clsvalue = modcfg.get('cls') clsvalue = modcfg.get('cls')
if clsvalue: if clsvalue:
if clsvalue.value == '<auto>': if clsvalue.strvalue == '<auto>':
widgets.append(IOWidget(self, key, modcfg)) widgets.append(IOWidget(self, key, modcfg))
continue continue
else: else:
modcfg['cls'] = Value('', ModuleClass, from_string=True) modcfg['cls'] = Value('', ModuleClass, from_string=True)
widgets.append(ModuleWidget(self, key, modcfg)) widgets.append(ModuleWidget(self, key, modcfg))
self.log.info('widgets %r', len(self.widgets))
self.widgets = widgets self.widgets = widgets
# self.dirty = False
def toggle_detailed(self): def toggle_detailed(self):
self.detailed = not self.detailed self.detailed = not self.detailed
@@ -628,7 +689,7 @@ class EditorMain(Main):
else: else:
key = super().get_key() key = super().get_key()
if self.version_view: 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') self.status('', 'can not edit previous version')
else: else:
break break
@@ -636,6 +697,15 @@ class EditorMain(Main):
break break
return key 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): def cut_module(self):
if not self.cut_modules: if not self.cut_modules:
self.cut_extend = False self.cut_extend = False
@@ -643,13 +713,21 @@ class EditorMain(Main):
if not isinstance(module, ModuleWidget): if not isinstance(module, ModuleWidget):
self.status('', warn='can not cut node') self.status('', warn='can not cut node')
return 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: if not self.cut_extend:
self.cut_modules = [] self.cut_modules = []
self.log.info('start cut modules') self.log.info('start cut modules')
self.cut_modules.append(module) self.cut_modules.append(module)
self.cut_extend = True 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): def insert_cut(self):
if self.cut_modules: if self.cut_modules:
@@ -658,20 +736,12 @@ class EditorMain(Main):
def set_node_name(self, name, cfgpath=None): def set_node_name(self, name, cfgpath=None):
if name == self.cfgname: if name == self.cfgname:
return None return
if not name: if not name:
return f'{name!r} is not a valid node name' raise ValueError(f'{name!r} is not a valid node name')
if self.pidfile: self.write_pidfile(name)
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
self.cfgname = name self.cfgname = name
self.titlebar.mid = name
versions_path = self.version_dir / f'{name}.versions' versions_path = self.version_dir / f'{name}.versions'
try: try:
sections = versions_path.read_text().split(VERSION_SEPARATOR) sections = versions_path.read_text().split(VERSION_SEPARATOR)
@@ -692,6 +762,8 @@ class EditorMain(Main):
try: try:
filecontent = cfgpath.read_text() filecontent = cfgpath.read_text()
self.cfgpath = cfgpath self.cfgpath = cfgpath
if cfgpath != self.tmppath:
self.titlebar.mid = str(cfgpath)
timestamp = time.strftime(TIMESTAMP_FMT, time.localtime(cfgpath.stat().st_mtime)) timestamp = time.strftime(TIMESTAMP_FMT, time.localtime(cfgpath.stat().st_mtime))
break break
except FileNotFoundError: except FileNotFoundError:
@@ -702,13 +774,15 @@ class EditorMain(Main):
self.cfgpath = cfgpaths[0] self.cfgpath = cfgpaths[0]
self.filecontent = filecontent self.filecontent = filecontent
self.add_version(filecontent, timestamp) self.add_version(filecontent, timestamp)
return None
def add_version(self, filecontent, timestamp): def add_version(self, filecontent, timestamp):
if self.versions: if self.versions:
# remove last version if unchanged to_remove = []
key, content = next(reversed(self.versions.items())) # remove matching versions
if content == filecontent: for key, content in self.versions.items():
if content == filecontent:
to_remove.append(key)
for key in to_remove:
self.versions.pop(key) self.versions.pop(key)
if filecontent: if filecontent:
self.versions[timestamp] = filecontent self.versions[timestamp] = filecontent
@@ -726,8 +800,8 @@ class EditorMain(Main):
if not self.version_view: if not self.version_view:
self.status('this is already the current version') self.status('this is already the current version')
return return
self.popupmenu = menu = ConfirmDialog('restore this version? [N]') self.popupmenu = menu = tg.ConfirmDialog('restore this version? [N]')
if not self.popupmenu.handle(self): if not menu.handle(self):
self.status('cancelled restore') self.status('cancelled restore')
return return
version = list(self.versions)[-self.version_view] version = list(self.versions)[-self.version_view]
@@ -782,15 +856,22 @@ class EditorMain(Main):
widget.collect(cfgdata) widget.collect(cfgdata)
if 'node' not in cfgdata: if 'node' not in cfgdata:
raise ValueError(list(cfgdata), len(self.widgets)) raise ValueError(list(cfgdata), len(self.widgets))
config_code = cfgdata_to_py(**cfgdata) content = cfgdata_to_py(**cfgdata)
# if self.cfgpath: # if self.cfgpath:
# self.cfgpath.write_text(config_code) # self.cfgpath.write_text(config_code)
self.tmppath.write_text(config_code) self.tmppath.write_text(content)
return config_code self.filecontent = content
def finish(self, exc): def quit(self):
# TODO: ask where to save tmp file
self.save() 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): def advance(self, step):
done = super().advance(step) done = super().advance(step)
@@ -798,14 +879,41 @@ class EditorMain(Main):
self.get_focus_widget().set_focus(None, step) self.get_focus_widget().set_focus(None, step)
return done 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): def run(self):
try: try:
self.pidfile.write_text(str(os.getpid())) super().run()
super().run(Writer)
except Exception: except Exception:
print(formatExtendedTraceback()) print(formatExtendedTraceback())
finally: finally:
self.pidfile.unlink() if self.pidfile:
self.pidfile.unlink()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -24,11 +24,14 @@ import frappy
from pathlib import Path from pathlib import Path
from ast import literal_eval from ast import literal_eval
from importlib import import_module 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.core import Module
from frappy.datatypes import DataType from frappy.datatypes import DataType
HEADER = "# please edit this file with frappy edit"
class Site: class Site:
domain = 'psi.ch' domain = 'psi.ch'
frappy_subdir = 'frappy_psi' frappy_subdir = 'frappy_psi'
@@ -167,11 +170,8 @@ class Value:
def validate_from_string(self, strvalue): def validate_from_string(self, strvalue):
self.strvalue = strvalue self.strvalue = strvalue
self.value = self.callback(self.datatype.from_string(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): def set_from_string(self, strvalue):
self.strvalue = strvalue
try: try:
self.validate_from_string(strvalue) self.validate_from_string(strvalue)
except Exception as e: except Exception as e:
@@ -190,7 +190,7 @@ class Value:
return repr(self.strvalue) return repr(self.strvalue)
def __repr__(self): 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): def get_datatype(pname, cls, value):
@@ -259,7 +259,6 @@ class ClassChecker:
error = None error = None
else: else:
error = 'empty element' error = 'empty element'
if error: if error:
self.name = name self.name = name
self.error = error self.error = error
@@ -300,6 +299,8 @@ class ModuleClass(DataType):
checker = ClassChecker(value) checker = ClassChecker(value)
if checker.error: if checker.error:
raise ValueError(checker.error) raise ValueError(checker.error)
if checker.clsobj is None:
raise ValueError(value)
return checker.clsobj return checker.clsobj
@classmethod @classmethod
@@ -343,9 +344,15 @@ def moddata_from_cfgfile(name, cls, **kwds):
def moddata_to_py(name, cls, description, **kwds): def moddata_to_py(name, cls, description, **kwds):
if '<' in cls.get_repr(): if cls.strvalue == '<auto>':
raise ValueError(cls) uri = kwds.pop('uri')
items = [f'Mod({name!r}', cls.get_repr(), description.get_repr()] 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 = {} paramdict = {}
for name, valobj in kwds.items(): for name, valobj in kwds.items():
param, _, prop = name.partition('.') param, _, prop = name.partition('.')
@@ -364,6 +371,8 @@ def moddata_to_py(name, cls, description, **kwds):
# extend with keyworded values for parameter properties # extend with keyworded values for parameter properties
args.extend(f'{k}={v.get_repr()}' for k, v in props.items()) args.extend(f'{k}={v.get_repr()}' for k, v in props.items())
items.append(f"{name} = Param({', '.join(args)})") items.append(f"{name} = Param({', '.join(args)})")
if len(items) == 1:
return f"{items[0]})"
items.append(')') items.append(')')
return ',\n '.join(items) 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()} return {k: Value(v) for k, v in props.items()}
def nodedata_to_py(name, equipment_id, title, doc, interface=None, cls=None, **kwds): 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) 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 intfc = site.default_interface if interface is None else interface.value
desc = title.value.strip() desc = title.value.strip()
doc = doc.value.strip() doc = doc.value.strip()
@@ -425,18 +434,57 @@ def cfgdata_to_py(node, **moddata):
"""convert cfgdata to python code """convert cfgdata to python code
:param node: dict <key> of <value object> :param node: dict <key> of <value object>
:param cfgdata: dict <module name> of dict <key> of <value object> :param moddata: dict <module name> of dict <key> of <value object>
:return: python code :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) return '\n\n'.join(items)
def cfgdata_from_py(name, cfgpath, filecontent, logger): def cfgdata_from_py(name, cfgpath, filecontent, logger):
if filecontent: if filecontent:
config = process_file(cfgpath, logger, filecontent) config = process_file(cfgpath, logger, filecontent)
iodict = {k: v for k, v in config.items() if v.get('cls') == '<auto>'}
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: else:
config = {} config = {}
nodecfg = config.pop('node', {}) iodict = {}
modcfg = {k: moddata_from_cfgfile(k, **v) for k, v in config.items()} nodecfg = {}
return nodedata_from_cfgfile(name, **nodecfg), modcfg
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'] = '<auto>'
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

View File

@@ -24,6 +24,60 @@ import threading
from select import select 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): class Key(int):
def __new__(cls, name, nr): def __new__(cls, name, nr):
if isinstance(nr, str): if isinstance(nr, str):
@@ -33,41 +87,18 @@ class Key(int):
key.name = name key.name = name
return key return key
def __repr__(self): def short(self):
"""as __repr__, but ctrl keys are translated to ^<letter>"""
if 0 <= self < 32:
if 0 < self <= 26:
return f'^{chr(96 + self)}'
return f'^{chr(64 + self)}'
return self.name return self.name
class Keys: def __repr__(self):
def __init__(self, **kwds): """name by function"""
# mapping int -> letter return self.name
self.bynumber = {k: chr(k) for k in range(32, 127)}
self.keys = {}
self.add(**kwds)
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): def clamp(*args):
return sorted(args)[len(args) // 2] return sorted(args)[len(args) // 2]
@@ -100,6 +131,7 @@ class Widget:
default_height = 1 default_height = 1
log = Logger() log = Logger()
context_menu = None context_menu = None
default_width = 21
def get_menu(self): def get_menu(self):
if self.context_menu: if self.context_menu:
@@ -118,10 +150,13 @@ class Widget:
"""returns current row""" """returns current row"""
return 0 return 0
def height(self): def height(self, to_focus=None):
"""returns current height""" """returns current height"""
return self.default_height return self.default_height
def width(self):
return self.default_width
# def draw(self, wr, in_focus=False): # def draw(self, wr, in_focus=False):
# raise NotImplementedError # raise NotImplementedError
@@ -131,23 +166,22 @@ class HasWidgets:
widgets = None # list of subwidgets widgets = None # list of subwidgets
def current_row(self): def current_row(self):
height = sum(w.height() for w in self.widgets[:self.focus]) return self.height(self.focus) + self.get_focus_widget().current_row()
return height + self.get_focus_widget().current_row()
def height(self): def height(self, to_focus=None):
return sum(w.height() for w in self.widgets) 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): def handle(self, main):
try: try:
while True: while True:
main.current_widget = self.get_focus_widget() main.current_widget = self.get_focus_widget()
key = main.current_widget.handle(main) key = main.current_widget.handle(main)
# if key in (CTRL_X, K.LEFT): if key == KEY.UP:
# return key
if key == K.UP:
if self.advance(-1): if self.advance(-1):
continue continue
elif key in (K.DOWN, K.RETURN, K.ENTER): elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER):
if self.advance(1): if self.advance(1):
continue continue
return key return key
@@ -199,17 +233,20 @@ class Container(HasWidgets, Widget):
class TitleBar(Widget): class TitleBar(Widget):
default_height = 1 default_height = 1
def __init__(self, left, right=''): def __init__(self, left, mid='', right=''):
self.left = left self.left = left
self.mid = mid
self.right = right self.right = right
def draw(self, wr, in_focus=False): 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() wr.startrow()
text = self.left.ljust(wr.width) wr.bar(text + ' ')
wr.bar(text)
if self.right:
wr.col = wr.width - len(self.right) - 3
wr.bar(f' {self.right} ')
class StatusBar(Widget): class StatusBar(Widget):
@@ -233,7 +270,6 @@ class StatusBar(Widget):
else: else:
wr.wr(self.text[:wid].rjust(wid) + ' ', wr.barstyle) wr.wr(self.text[:wid].rjust(wid) + ' ', wr.barstyle)
def set(self, text, warn=None, query=False): def set(self, text, warn=None, query=False):
self.text = text self.text = text
self.warn = warn self.warn = warn
@@ -257,6 +293,9 @@ class TextEdit(Widget):
self.col_offset = 0 self.col_offset = 0
self.finish_callback = finish_callback self.finish_callback = finish_callback
def width(self):
return max(self.minwidth, len(self.value))
def draw(self, wr, in_focus=False): def draw(self, wr, in_focus=False):
text = self.value[self.col_offset:] text = self.value[self.col_offset:]
if in_focus: if in_focus:
@@ -291,22 +330,22 @@ class TextEdit(Widget):
try: try:
while True: while True:
key = self.get_key(main) key = self.get_key(main)
if key == K.LEFT: if key == KEY.LEFT:
if self.highlighted: if self.highlighted:
return key return key
self.pos = max(0, self.pos - 1) self.pos = max(0, self.pos - 1)
continue continue
if key == K.TAB: if key == KEY.TAB:
self.highlighted = not self.highlighted self.highlighted = not self.highlighted
self.pos = 0 self.pos = 0
continue continue
if key == K.BEG_LINE: if key == KEY.BEG_LINE:
self.pos = 0 self.pos = 0
elif key == K.END_LINE: elif key == KEY.END_LINE:
self.pos = len(self.value) 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) 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: if self.highlighted:
self.value = '' self.value = ''
self.pos = 0 self.pos = 0
@@ -319,9 +358,9 @@ class TextEdit(Widget):
self.value = self.value[:self.pos] + key + self.value[self.pos:] self.value = self.value[:self.pos] + key + self.value[self.pos:]
self.pos += 1 self.pos += 1
elif key is not None: elif key is not None:
if key == K.ESC: if key == KEY.ESC:
save = False save = False
return K.DOWN return KEY.DOWN
return key return key
self.highlighted = False self.highlighted = False
finally: finally:
@@ -344,8 +383,8 @@ class TextEdit(Widget):
class NameEdit(TextEdit): class NameEdit(TextEdit):
def get_key(self, main): def get_key(self, main):
key = super().get_key(main) key = super().get_key(main)
if key == K.TAB: if key == KEY.TAB:
key = K.ENTER key = KEY.ENTER
return key return key
@@ -368,11 +407,6 @@ class TextEditCompl(TextEdit):
self.completion_pos = 0 self.completion_pos = 0
self.completion = completion 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): def enter(self, main):
main.popupmenu = None main.popupmenu = None
@@ -397,35 +431,39 @@ class TextEditCompl(TextEdit):
self.get_selection_menu(main, self.value) self.get_selection_menu(main, self.value)
key = super().get_key(main) key = super().get_key(main)
self.menu = menu = main.popupmenu 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 self.menu = main.popupmenu = None
return key return key
menu.pos = 1 menu.pos = 1
key = menu.get_key(main) key = menu.get_key(main)
# we have left the popup menu with returned key # we have left the popup menu with returned key
if key == K.UP: if key == KEY.UP:
return None return None
if key == K.LEFT: if key == KEY.LEFT:
self.pos = self.completion_pos self.pos = self.completion_pos
return key return key
value = self.value[:self.completion_pos] value = self.value[:self.completion_pos]
if key in (K.TAB, K.RETURN, K.ENTER, K.RIGHT): if key in (KEY.TAB, KEY.RETURN, KEY.ENTER, KEY.RIGHT):
selected = menu.get_value() selected = menu.get_value()
value += selected 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)): and self.completion(value)[0] < len(value)):
key = K.RIGHT key = KEY.RIGHT
self.value = value self.value = value
self.pos = len(value) self.pos = len(value)
self.menu = main.popupmenu = None self.menu = main.popupmenu = None
return key return key
class TextWidget(Widget): class TextWidget(Widget):
# minwidth = 16 # minwidth = 16
def __init__(self, text): def __init__(self, text):
self.value = text self.value = text
def width(self):
return len(self.value)
def draw(self, wr, in_focus=False): def draw(self, wr, in_focus=False):
wr.norm(self.value) wr.norm(self.value)
@@ -438,6 +476,9 @@ class LineEdit(Widget):
self.valuewidget = valuewidget self.valuewidget = valuewidget
self.focus = 1 self.focus = 1
def width(self):
return self.labelwidget.width() + 2 + self.valuewidget.width()
def handle(self, main): def handle(self, main):
name = self.labelwidget.value if isinstance(self.labelwidget, TextEdit) else None name = self.labelwidget.value if isinstance(self.labelwidget, TextEdit) else None
if name == '': if name == '':
@@ -445,14 +486,14 @@ class LineEdit(Widget):
while True: while True:
if self.focus: if self.focus:
key = self.valuewidget.handle(main) key = self.valuewidget.handle(main)
if key == K.LEFT: if key == KEY.LEFT:
if name is not None: if name is not None:
self.focus = 0 self.focus = 0
continue continue
return key return key
key = self.labelwidget.handle(main) key = self.labelwidget.handle(main)
self.focus = 1 self.focus = 1
if key in (K.ENTER, K.RETURN, K.TAB): if key in (KEY.ENTER, KEY.RETURN, KEY.TAB):
key = None key = None
return key return key
@@ -478,8 +519,10 @@ class MultiLineEdit(Container):
self.validator = validator self.validator = validator
self.highlighted = False self.highlighted = False
self.cut_menuitems = [ self.cut_menuitems = [
MenuItem('cut line(s)', K.CUT), MenuItem('cut line(s)', KEY.CUT),
MenuItem('paste line(s)', K.PASTE)] 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): def get_menu(self):
return self.cut_menuitems + self.parent.get_menu() return self.cut_menuitems + self.parent.get_menu()
@@ -507,10 +550,10 @@ class MultiLineEdit(Container):
self.highlighted = True self.highlighted = True
key = main.get_key() key = main.get_key()
self.highlighted = False self.highlighted = False
if key in (K.RIGHT, K.TAB): if key in (KEY.RIGHT, KEY.TAB):
self.set_focus(None, -1) self.set_focus(None, -1)
self.next_key = K.RIGHT self.next_key = KEY.RIGHT
elif isinstance(key, str) or key == K.DEL: elif isinstance(key, str) or key == KEY.DEL:
self.set_focus(None, -1) self.set_focus(None, -1)
last = self.get_focus_widget() last = self.get_focus_widget()
if self.focus == 0: if self.focus == 0:
@@ -520,21 +563,21 @@ class MultiLineEdit(Container):
self.widgets.append(LineOfMultiline(self, '')) self.widgets.append(LineOfMultiline(self, ''))
self.advance(1) self.advance(1)
self.next_key = key self.next_key = key
elif key == K.LEFT: elif key == KEY.LEFT:
self.set_focus(None, 0) self.set_focus(None, 0)
self.next_key = K.LEFT self.next_key = KEY.LEFT
else: else:
return key return key
while True: while True:
widget = self.get_focus_widget() widget = self.get_focus_widget()
key = widget.handle(main) key = widget.handle(main)
if key == K.UP: if key == KEY.UP:
if self.advance(-1): if self.advance(-1):
continue continue
elif key in (K.DOWN, K.RETURN, K.ENTER): elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER):
if self.advance(1): if self.advance(1):
continue continue
elif key == K.GO_UP: elif key == KEY.GO_UP:
continue continue
return key return key
@@ -564,12 +607,12 @@ class LineOfMultiline(TextEdit):
else: else:
self.parent.next_key = None self.parent.next_key = None
multiline = self.parent multiline = self.parent
if key == K.RETURN: if key == KEY.RETURN:
self.value, nextline = self.value[:self.pos], self.value[self.pos:] self.value, nextline = self.value[:self.pos], self.value[self.pos:]
multiline.widgets.insert(multiline.focus + 1, LineOfMultiline(multiline, nextline)) multiline.widgets.insert(multiline.focus + 1, LineOfMultiline(multiline, nextline))
main.cursor_col = 0 main.cursor_col = 0
return K.DOWN return KEY.DOWN
if key == K.DEL: if key == KEY.DEL:
if self.pos == 0: if self.pos == 0:
if multiline.focus > 0: if multiline.focus > 0:
thisline = self.value thisline = self.value
@@ -578,21 +621,19 @@ class LineOfMultiline(TextEdit):
pos = len(prev.value) pos = len(prev.value)
prev.value += thisline prev.value += thisline
main.cursor_col = pos main.cursor_col = pos
return K.UP return KEY.UP
elif key == K.LEFT: elif key == KEY.LEFT:
if self.pos == 0 and multiline.focus > 0: if self.pos == 0 and multiline.focus > 0:
prev = multiline.widgets[multiline.focus - 1] prev = multiline.widgets[multiline.focus - 1]
main.cursor_col = len(prev.value) main.cursor_col = len(prev.value)
# self.log.info('LEFT %r prev=%r', main.cursor_col, prev.value) return KEY.UP
return K.UP elif key == KEY.RIGHT:
elif key == K.RIGHT:
if self.pos == len(self.value) and multiline.focus < len(multiline.widgets)-1: 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 main.cursor_col = 0
return K.DOWN return KEY.DOWN
elif key in (K.UP, K.DOWN): elif key in (KEY.UP, KEY.DOWN):
return key return key
elif key == K.CUT: elif key == KEY.CUT:
if not main.cut_lines: if not main.cut_lines:
main.cut_extend = False main.cut_extend = False
if not main.cut_extend: if not main.cut_extend:
@@ -602,12 +643,11 @@ class LineOfMultiline(TextEdit):
multiline.widgets[i:i+1] = [] multiline.widgets[i:i+1] = []
main.cut_extend = True main.cut_extend = True
main.status(f'{len(main.cut_lines)} lines buffered') main.status(f'{len(main.cut_lines)} lines buffered')
return K.GO_UP return KEY.GO_UP
elif key == K.PASTE: elif key == KEY.PASTE:
multiline.widgets[multiline.focus: multiline.focus] = [ multiline.widgets[multiline.focus: multiline.focus] = [
LineOfMultiline(multiline, v) for v in main.cut_lines] LineOfMultiline(multiline, v) for v in main.cut_lines]
return K.GO_UP return KEY.GO_UP
# self.log.info('SET COL %r', self.pos)
self.cursor_col = self.pos self.cursor_col = self.pos
return key return key
@@ -636,9 +676,9 @@ class PopUpMenu:
def get_key(self, main): def get_key(self, main):
while True: while True:
key = main.get_key() key = main.get_key()
if key == K.DOWN: if key == KEY.DOWN:
self.advance(1) self.advance(1)
elif key == K.UP: elif key == KEY.UP:
self.advance(-1) self.advance(-1)
else: else:
return key return key
@@ -661,7 +701,7 @@ class CompletionMenu(PopUpMenu):
height = self.height + 1 height = self.height + 1
if self.pos == 0: if self.pos == 0:
# rectangle is open on top # 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: else:
wr.rectangle(row - self.pos - 1, col, height, self.width + 1) wr.rectangle(row - self.pos - 1, col, height, self.width + 1)
for i, value in enumerate(self.selection): for i, value in enumerate(self.selection):
@@ -673,6 +713,113 @@ class CompletionMenu(PopUpMenu):
wr.menu(value.ljust(self.width)) 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): class ConfirmDialog(Widget):
def __init__(self, query, positive_answers=('Y', 'y')): def __init__(self, query, positive_answers=('Y', 'y')):
self.query = query self.query = query
@@ -697,7 +844,7 @@ class ConfirmDialog(Widget):
class MenuItem: 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 """a menu item for a context menu
:param name: the displayed command name :param name: the displayed command name
@@ -710,7 +857,7 @@ class MenuItem:
self.shortcut = shortcut or '' self.shortcut = shortcut or ''
if shortcut: if shortcut:
if isinstance(shortcut, Key): if isinstance(shortcut, Key):
self.shortcut = f'^{chr(shortcut+96)}' self.shortcut = shortcut.short()
key = shortcut key = shortcut
letter = chr(96 + shortcut) letter = chr(96 + shortcut)
self.keys = (shortcut, letter) self.keys = (shortcut, letter)
@@ -724,7 +871,6 @@ class MenuItem:
self.returnkey = returnkey self.returnkey = returnkey
else: else:
self.returnkey = key self.returnkey = key
self.width = len(name)
def do_action(self): def do_action(self):
if self.action: if self.action:
@@ -743,14 +889,13 @@ class ContextMenu(PopUpMenu):
""" """
self.menuitems = [MenuItem('')] + menuitems self.menuitems = [MenuItem('')] + menuitems
self.height = len(self.menuitems) self.height = len(self.menuitems)
self.width = max(v.width for v in menuitems)
# self.selection = [v.name 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} self.hotkeys = {k: m for m in menuitems for k in m.keys}
def do_hotkey(self, key): def do_hotkey(self, key):
menuitem = self.hotkeys.get(key) menuitem = self.hotkeys.get(key)
if not menuitem: if not menuitem:
return K.UNHANDLED return KEY.UNHANDLED
return menuitem.do_action() return menuitem.do_action()
def do_action(self): def do_action(self):
@@ -765,20 +910,21 @@ class ContextMenu(PopUpMenu):
wr.left = max(1, wr.left) wr.left = max(1, wr.left)
wr.nextrow = row = row + fixrow - toprow wr.nextrow = row = row + fixrow - toprow
col = wr.left - 1 col = wr.left - 1
width = max(len(v.name) for v in self.menuitems)
for i, menuitem in enumerate(self.menuitems): for i, menuitem in enumerate(self.menuitems):
wr.startrow() wr.startrow()
wr.menu(menuitem.shortcut.ljust(2)) wr.menu(menuitem.shortcut.ljust(2))
wr.norm(' ') wr.norm(' ')
if i == self.pos: if i == self.pos:
wr.high(menuitem.name, self.width) wr.high(menuitem.name, width)
else: else:
wr.menu(menuitem.name, self.width) wr.menu(menuitem.name, width)
wr.rectangle(row, col, height - 1, self.width + 4) wr.rectangle(row, col, height - 1, width + 4)
class BaseWriter: class BaseWriter:
"""base for writer. does nothing else than keeping track of position""" """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 = '! ' errorflag = '! '
def __init__(self): def __init__(self):
@@ -805,7 +951,7 @@ class BaseWriter:
self.bright(text, width) self.bright(text, width)
def bright(self, text, width=0): 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): def high(self, text, width=0):
self.wr(text.ljust(width), self.highstyle, extend_to=width) self.wr(text.ljust(width), self.highstyle, extend_to=width)
@@ -813,6 +959,9 @@ class BaseWriter:
def menu(self, text, width=0): def menu(self, text, width=0):
self.wr(text, self.menustyle, extend_to=width) 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): def error(self, text, width=0):
self.wr(f'{self.errorflag}{text}', self.errorstyle, extend_to=width) self.wr(f'{self.errorflag}{text}', self.errorstyle, extend_to=width)
@@ -843,9 +992,9 @@ class BaseWriter:
class Writer(BaseWriter): class Writer(BaseWriter):
highstyle = curses.A_REVERSE highstyle = curses.A_REVERSE
menustyle = curses.A_BOLD buttonstyle = curses.A_BOLD
barstyle = curses.A_REVERSE barstyle = curses.A_REVERSE
editstyle = 0 brightstyle = menustyle = 0
errorstyle = 0 errorstyle = 0
errorflag = '! ' errorflag = '! '
newoffset = None newoffset = None
@@ -922,14 +1071,14 @@ class Writer(BaseWriter):
dim_white = (680, 680, 680) dim_white = (680, 680, 680)
stdscr.bkgd(' ', cls.make_pair(black, dim_white)) stdscr.bkgd(' ', cls.make_pair(black, dim_white))
bright_white = 1000, 1000, 1000 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)) red = cls.make_color((680, 0, 0))
cls.errorstyle = cls.make_pair(red, dim_white) cls.errorstyle = cls.make_pair(red, dim_white)
cls.errorflag = '' cls.errorflag = ''
very_light_blue = (800, 900, 1000) very_light_blue = (800, 900, 1000)
cls.highstyle = cls.make_pair(black, very_light_blue) cls.highstyle = cls.make_pair(black, very_light_blue)
light_white = (900, 900, 900) light_white = (800, 800, 800)
cls.menustyle = cls.make_pair(black, light_white) cls.buttonstyle = cls.make_pair(black, light_white)
light_green = 0, 1000, 0 light_green = 0, 1000, 0
cls.querystyle = cls.make_pair(black, light_green) cls.querystyle = cls.make_pair(black, light_green)
yellow = 1000, 1000, 0 yellow = 1000, 1000, 0
@@ -957,7 +1106,7 @@ class Writer(BaseWriter):
self.cursor_visible = visible self.cursor_visible = visible
self.main.cursor_pos = self.row - self.offset, self.col + pos 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 """draw a vertical line
:param row, col: upper start point :param row, col: upper start point
@@ -978,10 +1127,10 @@ class Writer(BaseWriter):
length -= top - beg length -= top - beg
beg = None beg = None
if length > 0: 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 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 """draw a horizontal line
:param row, col: left start point :param row, col: left start point
@@ -1002,36 +1151,34 @@ class Writer(BaseWriter):
length -= left - beg length -= left - beg
beg = None beg = None
if length > 0: if length > 0:
self.scr.hline(row, col, curses.ACS_HLINE, length) self.scr.hline(row, col, curses.ACS_HLINE, length, *attr)
else: else:
raise ValueError(length) raise ValueError(length)
return None, None return None, None
if end is None and beg is not None:
raise ValueError('end None')
return beg, end 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""" """clipped rectangle"""
row = row - self.offset row = row - self.offset
clip = ( args = (
max(0, (top or 0) - self.offset), max(0, clip.get('top', 0) - self.offset),
self.height if bottom is None else min(self.height, bottom - self.offset), min(self.height, clip.get('bottom', self.height + self.offset) - self.offset),
max(0, (left or 0)), max(0, clip.get('left', 0)),
self.width if right is None else min(self.width, right) min(self.width, clip.get('right', self.width))
) ) + attr
self.vline(row, col, height, *clip) self.vline(row, col, height, *args)
self.vline(row, col + width, height, *clip) self.vline(row, col + width, height, *args)
left, right = self.hline(row, col, width, *clip) left, right = self.hline(row, col, width, *args)
if left is not None: 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: if right is not None:
self.scr.addch(row, right, curses.ACS_URCORNER) self.scr.addch(row, right, curses.ACS_URCORNER, *attr)
row += height row += height
left, right = self.hline(row, col, width, *clip) left, right = self.hline(row, col, width, *args)
if left is not None: 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: 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): class Empty(Widget):
@@ -1042,20 +1189,27 @@ class Empty(Widget):
HELP_TEXT = """ HELP_TEXT = """
ctrl-X Exit ctrl-X Context Menu
ctrl-C context menu ctrl-Q Quit
""" """
class Main(HasWidgets): class Main(HasWidgets):
parent = None parent = None
popupmenu = None popupmenu = None
statusbar = None statusbar = None
menubar = None menubar = None
context_menu = None
help_text = HELP_TEXT help_text = HELP_TEXT
log = Widget.log log = Widget.log
leftwidth = 0.2 # if < 1: a fraction 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.focus = 0
self.headers = headers self.headers = headers
self.footers = footers self.footers = footers
@@ -1070,16 +1224,50 @@ class Main(HasWidgets):
self.cursor_col = 0 # position of cursor in MultiLineEdit self.cursor_col = 0 # position of cursor in MultiLineEdit
self.cursor_pos = (0, 0) self.cursor_pos = (0, 0)
self.popup_offset = 0 self.popup_offset = 0
self.quit = False self.quit_action = None
self.cut_lines = [] self.cut_lines = []
self.cut_extend = False self.cut_extend = False
self.context_menu = [
MenuItem('help', K.HELP, self.handle_help, None), def run(self):
MenuItem('exit', K.QUIT, self.do_quit), 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): def do_quit(self):
self.quit = True self.quit_action = self.quit
return KEY.GOTO_MAIN
def quit(self):
pass
def get_topmargin(self): def get_topmargin(self):
return sum(v.height() for v in self.headers) return sum(v.height() for v in self.headers)
@@ -1098,46 +1286,40 @@ class Main(HasWidgets):
key = self.scr.getch() key = self.scr.getch()
else: else:
continue continue
key = K.bynumber.get(key, key) key = KEY.bynumber.get(key, key)
self.status(None) self.status(None)
if isinstance(key, str): if isinstance(key, str):
self.log.info('key letter %r', key)
self.cut_extend = False self.cut_extend = False
return key return key
if 0 <= key < 32: # control keys if 0 <= key < 32: # control keys
self.log.info('key ctrl %r', key) if key != KEY.CUT:
if key != K.CUT:
self.cut_extend = False self.cut_extend = False
self.log.info('extend False') menuitems = self.current_widget.get_menu()
menu = ContextMenu(self.current_widget.get_menu()) if menuitems and not self.popupmenu:
if key == K.MENU: menu = ContextMenu(menuitems)
try: if key == KEY.MENU:
if self.popupmenu: try:
return key if self.popupmenu:
self.popupmenu = menu return key
key = menu.get_key(self) self.popupmenu = menu
if key == K.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 continue
if key in (K.TAB, K.RETURN, K.ENTER): return result
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
else: else:
if key != K.GOTO_MAIN: if key != KEY.GOTO_MAIN:
if self.cut_extend:
self.log.info('extend False %r', key)
self.cut_extend = False self.cut_extend = False
self.log.info('key special %r', key)
return key return key
def handle_help(self): def handle_help(self):
@@ -1145,10 +1327,10 @@ class Main(HasWidgets):
self.popupmenu = None self.popupmenu = None
key = self.get_key() key = self.get_key()
self.help_mode = False self.help_mode = False
return key if key == K.QUIT else None return key if key == KEY.QUIT else None
def persistent_status(self): 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): def current_row(self):
if self.help_mode: if self.help_mode:
@@ -1216,34 +1398,4 @@ class Main(HasWidgets):
self.statusbar.set(f'{text} {st}', warn) self.statusbar.set(f'{text} {st}', warn)
def finish(self, exc): def finish(self, exc):
pass return True
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)