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 pathlib import Path
from psutil import pid_exists
from frappy.core import Module
from frappy.errors import ConfigError
from frappy.lib import generalConfig
from frappy.lib.comparestring import compare
from frappy.lib import mkthread, formatExtendedTraceback
from frappy.config import process_file, to_config_path
from frappy.tools.configdata import Value, cfgdata_to_py, cfgdata_from_py, get_datatype, site, ModuleClass, stringtype
from frappy.tools.completion import class_completion, recommended_prs
from frappy.tools.terminalgui import Main, Container, LineEdit, MultiLineEdit, MenuItem, Writer, ContextMenu, \
TextWidget, TextEdit, NameEdit, TextEditCompl, Completion, TitleBar, StatusBar, Widget, ConfirmDialog, \
K
import frappy.tools.terminalgui as tg
from frappy.tools.terminalgui import Main, MenuItem, TextEdit, PushButton, ModalDialog, KEY
KEY.add(
TOGGLE_DETAILED='^t',
NEXT_VERSION='^n',
PREV_VERSION='^b',
)
K.add(TOGGLE_DETAILED='^t', NEXT_VERSION='^n', PREV_VERSION='^b')
VERSION_SEPARATOR = "\n''''"
TIMESTAMP_FMT = '%Y-%m-%d-%H%M%S'
# TODO:
# - ctrl-K / ctrlV/U for cutting/pasting module(s) or parameter(2)
# * separate buffer for modules and parameters?
# * when inserting a parameter which already exists, overwrite the value only
# - implement IO modules
# - version control: create one file, with a separator (save disk space, no numbering housekeeping needed)
# - use Exceptions for ctrl-X and ctrl-C
# - ctrl-K: on previous versions copy and advance to next module
# - use also shift-Tab for level up?
# - directory to save to
# - remove older versions, for which a newer exist already: a restore removes the older one
def unix_cmd(cmd, *args):
out = Popen(cmd.split() + list(args), stdout=PIPE).communicate()[0]
@@ -55,7 +55,7 @@ class TopWidget:
parent_cls = Main
class Child(Widget):
class Child(tg.Widget):
"""child widget of NodeWidget ot ModuleWidget"""
parent = TopWidget
@@ -81,23 +81,21 @@ class HasValue(Child):
def validate(self, strvalue, main=None):
pname = self.get_name()
valobj = self.valobj
prev = valobj.value, valobj.strvalue, valobj.error
try:
if pname != 'cls':
if self.clsobj != self.parent.clsobj:
self.clsobj = self.parent.clsobj
self.log.info('validate %r %r %r %r', self.parent.get_name(), self.get_name(), self.clsobj, self.valobj)
self.valobj.datatype, self.valobj.error = get_datatype(
self.get_name(), self.clsobj, self.valobj.value)
self.log.info('validate %r %r %r %r', self.parent.get_name(), self.get_name(), self.clsobj, self.valobj)
self.valobj.validate_from_string(strvalue)
valobj.datatype, valobj.error = get_datatype(
self.get_name(), self.clsobj, valobj.value)
valobj.validate_from_string(strvalue)
self.error = None
except Exception as e:
self.error = str(e)
if self.get_name() == 'tolerance':
self.log.info('checked %r %r', self.valobj, self.valobj.error)
if main:
if main and (valobj.value, valobj.strvalue, valobj.error) != prev:
main.touch()
return strvalue
return valobj.strvalue
def check_data(self):
self.validate(self.valobj.strvalue)
@@ -106,7 +104,7 @@ class HasValue(Child):
return self.get_name() and self.valobj.strvalue
class ValueWidget(HasValue, LineEdit):
class ValueWidget(HasValue, tg.LineEdit):
fixedname = None
def __init__(self, parent, name, valobj, label=None):
@@ -119,13 +117,13 @@ class ValueWidget(HasValue, LineEdit):
"""
self.init_value_widget(parent, valobj)
if label is not None:
labelwidget = TextWidget(label)
labelwidget = tg.TextWidget(label)
self.fixedname = name
else:
labelwidget = NameEdit(name, self.validate_name)
labelwidget = tg.NameEdit(name, self.validate_name)
# self.log.info('value widget %r %r', name, self.fixedname)
if valobj.completion:
valueedit = TextEditCompl(valobj.strvalue, self.validate, valobj.completion)
valueedit = tg.TextEditCompl(valobj.strvalue, self.validate, valobj.completion)
else:
valueedit = TextEdit(valobj.strvalue, self.validate)
super().__init__(labelwidget, valueedit)
@@ -159,7 +157,7 @@ class ValueWidget(HasValue, LineEdit):
as_dict[name] = self.valobj
class DocWidget(HasValue, MultiLineEdit):
class DocWidget(HasValue, tg.MultiLineEdit):
parent_cls = TopWidget
def __init__(self, parent, name, valobj):
@@ -176,11 +174,12 @@ class DocWidget(HasValue, MultiLineEdit):
config[self.name] = self.valobj
class BaseWidget(TopWidget, Container):
class BaseWidget(TopWidget, tg.Container):
"""base for Module or Node"""
clsobj = None
header = 'Module'
header = None
special_names = 'name', 'cls', 'description'
endline_help = 'RET: add module p: add property'
def init(self, parent):
self.widgets = []
@@ -216,16 +215,22 @@ class BaseWidget(TopWidget, Container):
def new_widget(self):
raise NotImplementedError
def add_module(self, after_current=False):
modcfg = {'name': Value(''), 'cls': Value(f'{site.frappy_subdir}.'), 'description': Value('')}
def insert_module(self, module, after_current=False):
main = self.parent
module = ModuleWidget(main, '', modcfg)
main.insert(main.focus + after_current, module)
main.set_focus(main.focus + 1)
if not after_current:
self.set_focus(1)
main.advance(-1)
module.set_focus(1) # go to cls widget
# module.set_focus(0) # go to name widget
def add_module(self, after_current=False):
modcfg = {'name': Value(''), 'cls': Value(f'{site.frappy_subdir}.'), 'description': Value('')}
self.insert_module(ModuleWidget(self.parent, '', modcfg), after_current)
def add_iomodule(self, after_current=False):
modcfg = {'name': Value(''), 'uri': Value('')}
self.insert_module(IOWidget(self.parent, '', modcfg), after_current)
def get_widget_value(self, key):
try:
@@ -248,14 +253,6 @@ class BaseWidget(TopWidget, Container):
else:
self.draw_summary(wr, in_focus)
def current_row(self):
main = self.parent
return super().current_row() if main.detailed else 0
def height(self):
main = self.parent
return super().height() if main.detailed else 1
def collect(self, result):
name = self.get_name()
if name:
@@ -280,6 +277,9 @@ class ModuleName(Value):
class ModuleWidget(BaseWidget):
header = 'Module'
endline_help = 'RET: add module i: add io module p: add parameter or property'
def __init__(self, parent, name, modulecfg):
assert name == modulecfg['name'].value
modulecfg['name'] = ModuleName(parent, name)
@@ -287,20 +287,24 @@ class ModuleWidget(BaseWidget):
self.context_menu = [
MenuItem('add parameter/property', 'p', self.new_widget),
MenuItem('add module', 'm', self.add_module),
MenuItem('add io module', 'i', self.add_iomodule),
MenuItem('purge empty prs', 'e', self.purge_prs),
MenuItem('add recommended prs', '+', self.complete_prs),
MenuItem('cut module', K.CUT, parent.cut_module),
MenuItem('cut module', KEY.CUT, parent.cut_module),
]
clsvalue = modulecfg['cls']
self.configure_class(modulecfg.get('cls'))
for name, valobj in modulecfg.items():
self.add_widget(name, valobj)
self.widgets.append(EndLine(self))
def configure_class(self, clsvalue):
clsvalue.callback = self.update_cls
clsvalue.completion = class_completion
clsobj = clsvalue.value
if clsobj:
self.fixed_names.update({k: k for k, v in recommended_prs(clsobj).items() if v})
for name, valobj in modulecfg.items():
self.add_widget(name, valobj)
self.widgets.append(EndModule(self))
def new_widget(self, name='', pos=None):
self.add_widget(name, Value('', *get_datatype('', self.clsobj, ''), from_string=True), self.focus)
@@ -314,13 +318,21 @@ class ModuleWidget(BaseWidget):
def handle(self, main):
while True:
key = super().handle(main) if main.detailed else main.get_key()
if key in (K.RIGHT, K.TAB):
if key in (KEY.RIGHT, KEY.TAB):
main.detailed = True
main.status('')
main.offset = None # recalculate offset from screen pos
else:
return key
def current_row(self):
main = self.parent
return super().current_row() if main.detailed else 0
def height(self, to_focus=None):
main = self.parent
return super().height(to_focus) if main.detailed else 1
def check_data(self):
"""check clsobj is valid and check all params and props"""
# clswidget, = self.find_widgets('cls')
@@ -356,17 +368,20 @@ class ModuleWidget(BaseWidget):
self.widgets = [w for w in self.widgets if w.get_name() not in self.fixed_names and w.is_valid()]
self.update_widget_dict()
def draw_summary_right(self, wr):
half = (wr.width - wr.col) // 2
wr.norm(f"{self.get_widget_value('description').ljust(half)} {self.get_widget_value('cls')} ")
def draw_summary(self, wr, in_focus):
wr.startrow()
wr.norm('Module ')
wr.norm(self.header.ljust(7))
name = self.get_widget_value('name')
if in_focus:
wr.set_cursor_pos()
wr.bright(name, round(wr.width * 0.2))
else:
wr.norm(name.ljust(round(wr.width * 0.2)))
half = (wr.width - wr.col) // 2
wr.norm(f"{self.get_widget_value('description').ljust(half)} {self.get_widget_value('cls')} ")
self.draw_summary_right(wr)
def collect(self, result):
super().collect(result)
@@ -375,9 +390,42 @@ class ModuleWidget(BaseWidget):
assert result[name].pop('name').value == name
class EndNode(Child):
class IOWidget(ModuleWidget):
header = 'IO'
endline_help = 'RET: add module p: add property'
special_names = 'name', 'uri'
def __init__(self, parent, name, modulecfg):
urivalue = modulecfg.get('uri')
if urivalue is None:
modulecfg['uri'] = Value('uri', stringtype)
super().__init__(parent, name, modulecfg)
def add_widget(self, name, valobj, pos=None):
if name != 'cls' and (
name != 'description' or valobj.strvalue):
super().add_widget(name, valobj)
def draw_summary_right(self, wr):
half = (wr.width - wr.col) // 2
ioname = self.get_name()
modules = [w.get_name() for w in self.parent.widgets if w.get_widget_value('io') == ioname]
wr.norm(f"{self.get_widget_value('uri').ljust(half)} for {','.join(modules)} ")
def collect(self, result):
name = self.get_name()
if name:
super().collect(result)
modcfg = result[name]
modcfg['cls'] = Value('<auto>', stringtype)
modcfg.setdefault('description', Value('', stringtype))
def configure_class(self, clsvalue):
pass
class EndLine(Child):
parent_cls = TopWidget
helptext = 'RET: add module p: add parameter or property'
def __init__(self, parent):
self.init_parent(parent)
@@ -388,8 +436,7 @@ class EndNode(Child):
if in_focus:
wr.set_cursor_pos(wr.leftwidth)
wr.col = wr.leftwidth
wr.bright(' ')
wr.norm(' ' + self.helptext)
wr.high(self.parent.endline_help)
def collect(self, result):
pass
@@ -404,52 +451,29 @@ class EndNode(Child):
self.showhelp = False
while True:
key = main.get_key()
if key in (K.RETURN, K.ENTER):
if key in (KEY.RETURN, KEY.ENTER):
self.parent.add_module(True)
elif key in (K.UP, K.DOWN, K.QUIT):
elif key in (KEY.UP, KEY.DOWN, KEY.QUIT):
return key
elif key == 'i':
self.parent.add_iomodule(True)
elif key == 'p':
self.parent.new_widget()
return K.GOTO_MAIN
return KEY.GOTO_MAIN
return None
class EndModule(EndNode):
parent_cls = ModuleWidget
class IOWidget(ModuleWidget):
def __init__(self, parent, name, modulecfg):
assert name == modulecfg['name'].value
modulecfg['name'] = ModuleName(parent, name)
self.init(parent)
self.context_menu = [
MenuItem('add module', 'm', self.add_module),
MenuItem('cut module', K.CUT, parent.cut_module),
]
urivalue = modulecfg.get('uri')
if urivalue is None:
urivalue = Value('uri', stringtype)
self.add_widget('uri', urivalue)
self.widgets.append(EndModule(self))
class EndIO(EndNode):
parent_cls = IOWidget
helptext = 'RET: add module'
class NodeName(Value):
def __init__(self, main, name):
self.main = main
super().__init__(name)
def set_from_string(self, value):
self.error = self.main.set_node_name(value)
if self.error:
def validate_from_string(self, value):
try:
self.main.set_node_name(value)
except Exception:
self.value = self.strvalue = self.main.cfgname
else:
self.value = self.strvalue = value
raise
class NodeWidget(BaseWidget):
@@ -471,7 +495,7 @@ class NodeWidget(BaseWidget):
self.widget_dict['doc'] = docwidget
else:
self.add_widget(name, valobj)
self.widgets.append(EndNode(self))
self.widgets.append(EndLine(self))
def new_widget(self, name=''):
"""insert new widget at focus pos"""
@@ -481,43 +505,23 @@ class NodeWidget(BaseWidget):
return 'node'
def set_focus(self, focus, step=1):
self.log.info('node focus %r %r %r', self.focus, focus, step)
while super().set_focus(focus, step):
self.log.info('node find %r name %r', self.focus, self.get_focus_widget().get_name())
if self.parent.detailed or self.get_focus_widget().get_name() in self.summ_edit:
self.log.info('found')
return True
focus = self.focus + step
self.log.info('node focus end %r', self.focus)
def height(self):
def height(self, to_focus=None):
main = self.parent
return sum(w.height() for w in self.widgets if main.detailed or w.get_name() in self.summ_edit)
# def handle(self, main):
# if main.detailed:
# return super().handle(main)
# advance = self.next
# key = None
# itry = 10
# while itry > 0:
# widget = self.get_focus_widget()
# if widget.get_name() in ('title', 'doc'):
# key = widget.handle(main)
# itry = 10
# # if key in (CTRL_X, K.LEFT):
# # return key
# if key == K.UP:
# advance = self.prev
# elif key in (K.DOWN, K.RETURN, K.ENTER):
# advance = self.next
# else:
# return key
# if not advance():
# return key
# itry -= 1
# raise ValueError('x')
if not main.detailed:
return super().height(to_focus)
height = 0
if to_focus is None:
to_focus = len(self.widgets)
for nr, widget in enumerate(self.widgets[:to_focus]):
name = widget.get_name()
if name in self.summ_edit:
height += widget.height()
return height
def draw_summary(self, wr, in_focus):
# wr.startrow()
@@ -530,9 +534,66 @@ class NodeWidget(BaseWidget):
widget.draw(wr, nr == focus)
class SaveDialog(ModalDialog):
def __init__(self, filename):
self.fileedit = tg.DialogInput(self, 'file', str(filename))
self.result = None
super().__init__([self.fileedit,
PushButton(self, 'save and quit', self.save),
PushButton(self, 'quit without saving', self.nosave),
PushButton(self, 'cancel', self.cancel)])
def execute(self, main):
self.set_focus(1) # go to save button
super().execute(main)
if self.fileedit.result:
return self.save()
if self.result is None:
# no button was pressed
return self.cancel()
return self.result()
def save(self):
self.filename = self.fileedit.get_value()
return KEY.QUIT
def nosave(self):
self.filename = None
return KEY.QUIT
def cancel(self):
return None
HELP_TEXT = """
ctrl-X Exit
ctrl-T Toggle view mode (summary <-> detailed)
Frappy Configuration Editor
---------------------------
A configuration files has a Node section, followed by any number of IO and
Module sections. IO section typically just contain the name and an uri.
A Module sections key item is the 'cls', denoting the python class for
the implementation. Entering the class is supported by a completion popup
menu, which opens as soon as you start typing.
When opening a file, the editor is in summary mode, showing a compact
overview over all modules. Use ctrl-T to toggle to detailed view to
be able to edit individual items.
Modify entries
--------------
To enter a new value a field, start typing. To modify a value press ctrl-A
of ctrl-E to go the the start or end of the string.
Context Menu
-------------
Press ctrl-X to open a context menu. Navigate to an entry an press RETURN
or press the key indicated to the left to execute an action. A key starting
with ^ indicates to the given action may be performed with a ctrl-<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
def __init__(self, cfg):
self.titlebar = TitleBar('Frappy Cfg Editor')
super().__init__([], [self.titlebar], [StatusBar(self)])
self.titlebar = tg.TitleBar('Frappy Cfg Editor')
super().__init__([], tg.Writer, [self.titlebar], [tg.StatusBar(self)])
# self.select_menu = MenuItem('select module', CUT_KEY)
self.version_menu = [
MenuItem('previous version', K.PREV_VERSION, self.prev_version),
MenuItem('next version', K.NEXT_VERSION, self.next_version),
MenuItem('previous version', KEY.PREV_VERSION, self.prev_version),
MenuItem('next version', KEY.NEXT_VERSION, self.next_version),
MenuItem('restore this version', 'r', self.restore_version),
MenuItem('copy module', KEY.CUT, self.cut_module),
]
self.main_menu = [
MenuItem('show previous version', K.PREV_VERSION, self.prev_version),
MenuItem('show previous version', KEY.PREV_VERSION, self.prev_version),
]
self.detailed_menuitem = MenuItem('toggle detailed', K.TOGGLE_DETAILED, self.toggle_detailed)
self.cut_menuitem = MenuItem('insert cut modules', K.PASTE, self.insert_cut)
self.detailed_menuitem = MenuItem('toggle detailed', KEY.TOGGLE_DETAILED, self.toggle_detailed)
self.cut_menuitem = MenuItem('insert cut modules', KEY.PASTE, self.insert_cut)
self.version_dir = Path('~/.local/share/frappy_config_editor').expanduser()
self.version_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
self.cfgname = None
@@ -580,35 +642,34 @@ class EditorMain(Main):
else:
cfgpath = None
self.filecontent = None
error = self.set_node_name(cfg, cfgpath)
if error:
raise RuntimeError(error)
self.set_node_name(cfg, cfgpath)
self.init_from_content(self.filecontent)
self.module_clipboard = {}
self.pr_clipboard = {}
def get_menu(self):
self.detailed_menuitem.name = 'summary view' if self.detailed else 'detailed view'
cmenu = [self.cut_menuitem] if self.cut_modules else []
vmenu = self.version_menu if self.version_view else self.main_menu
return cmenu + vmenu + [self.detailed_menuitem] + self.context_menu
menu = self.version_menu if self.version_view else self.main_menu
if self.cut_modules and not self.version_view:
self.cut_menuitem.name = f'insert {self.describe_buffer()}'
menu.append(self.cut_menuitem)
return menu + [self.detailed_menuitem] + self.context_menu
def init_from_content(self, filecontent):
self.log.info('init from content %r', len(filecontent))
widgets = []
nodedata, moddata = cfgdata_from_py(self.cfgname, self.cfgpath, filecontent, self.log)
widgets.append(NodeWidget(self, self.cfgname, nodedata))
for key, modcfg in moddata.items():
clsvalue = modcfg.get('cls')
if clsvalue:
if clsvalue.value == '<auto>':
if clsvalue.strvalue == '<auto>':
widgets.append(IOWidget(self, key, modcfg))
continue
else:
modcfg['cls'] = Value('', ModuleClass, from_string=True)
widgets.append(ModuleWidget(self, key, modcfg))
self.log.info('widgets %r', len(self.widgets))
self.widgets = widgets
# self.dirty = False
def toggle_detailed(self):
self.detailed = not self.detailed
@@ -628,7 +689,7 @@ class EditorMain(Main):
else:
key = super().get_key()
if self.version_view:
if isinstance(key, str) or key in [K.DEL]:
if isinstance(key, str) or key in [KEY.DEL]:
self.status('', 'can not edit previous version')
else:
break
@@ -636,6 +697,15 @@ class EditorMain(Main):
break
return key
def describe_buffer(self,):
cm = self.cut_modules
if not cm:
return ''
if len(cm) > 1:
sep = ',' if len(cm) == 2 else '..'
return f'modules {cm[0].get_name()}{sep}{cm[-1].get_name()}'
return f'module {cm[0].get_name()}'
def cut_module(self):
if not self.cut_modules:
self.cut_extend = False
@@ -643,13 +713,21 @@ class EditorMain(Main):
if not isinstance(module, ModuleWidget):
self.status('', warn='can not cut node')
return
self.widgets[self.focus:self.focus+1] = []
if not self.version_view:
self.widgets[self.focus:self.focus+1] = []
if not self.cut_extend:
self.cut_modules = []
self.log.info('start cut modules')
self.cut_modules.append(module)
self.cut_extend = True
self.status(f'{len(self.cut_modules)} modules buffered')
if self.version_view:
text = 'copied'
if not self.set_focus(self.focus + 1):
if self.cut_modules[-1] == module:
self.cut_modules.pop()
else:
text = 'cut'
self.status(f'{self.describe_buffer()} {text}')
def insert_cut(self):
if self.cut_modules:
@@ -658,20 +736,12 @@ class EditorMain(Main):
def set_node_name(self, name, cfgpath=None):
if name == self.cfgname:
return None
return
if not name:
return f'{name!r} is not a valid node name'
if self.pidfile:
self.pidfile.unlink()
self.pidfile = self.version_dir / f'{name}.pid'
try:
pidstr = self.pidfile.read_text()
if pid_exists(int(pidstr)):
return f'{name} is already edited by process {pidstr}'
# overwrite pidfile from dead process
except FileNotFoundError:
pass
raise ValueError(f'{name!r} is not a valid node name')
self.write_pidfile(name)
self.cfgname = name
self.titlebar.mid = name
versions_path = self.version_dir / f'{name}.versions'
try:
sections = versions_path.read_text().split(VERSION_SEPARATOR)
@@ -692,6 +762,8 @@ class EditorMain(Main):
try:
filecontent = cfgpath.read_text()
self.cfgpath = cfgpath
if cfgpath != self.tmppath:
self.titlebar.mid = str(cfgpath)
timestamp = time.strftime(TIMESTAMP_FMT, time.localtime(cfgpath.stat().st_mtime))
break
except FileNotFoundError:
@@ -702,13 +774,15 @@ class EditorMain(Main):
self.cfgpath = cfgpaths[0]
self.filecontent = filecontent
self.add_version(filecontent, timestamp)
return None
def add_version(self, filecontent, timestamp):
if self.versions:
# remove last version if unchanged
key, content = next(reversed(self.versions.items()))
if content == filecontent:
to_remove = []
# remove matching versions
for key, content in self.versions.items():
if content == filecontent:
to_remove.append(key)
for key in to_remove:
self.versions.pop(key)
if filecontent:
self.versions[timestamp] = filecontent
@@ -726,8 +800,8 @@ class EditorMain(Main):
if not self.version_view:
self.status('this is already the current version')
return
self.popupmenu = menu = ConfirmDialog('restore this version? [N]')
if not self.popupmenu.handle(self):
self.popupmenu = menu = tg.ConfirmDialog('restore this version? [N]')
if not menu.handle(self):
self.status('cancelled restore')
return
version = list(self.versions)[-self.version_view]
@@ -782,15 +856,22 @@ class EditorMain(Main):
widget.collect(cfgdata)
if 'node' not in cfgdata:
raise ValueError(list(cfgdata), len(self.widgets))
config_code = cfgdata_to_py(**cfgdata)
content = cfgdata_to_py(**cfgdata)
# if self.cfgpath:
# self.cfgpath.write_text(config_code)
self.tmppath.write_text(config_code)
return config_code
self.tmppath.write_text(content)
self.filecontent = content
def finish(self, exc):
# TODO: ask where to save tmp file
def quit(self):
self.save()
savedialog = SaveDialog(self.cfgpath)
if savedialog.execute(self) == KEY.QUIT:
filename = savedialog.filename
if filename:
self.log.info('saved %r to %r', self.cfgname, filename)
Path(filename).write_text(self.filecontent)
return True
return False
def advance(self, step):
done = super().advance(step)
@@ -798,14 +879,41 @@ class EditorMain(Main):
self.get_focus_widget().set_focus(None, step)
return done
def write_pidfile(self, name):
pidfile = self.version_dir / f'{name}.pid'
mypid = os.getpid()
for itry in range(15):
try:
with open(pidfile, 'x') as f:
f.write(str(mypid))
if self.pidfile and self.pidfile.exists():
self.pidfile.unlink()
self.pidfile = pidfile
return None
except FileExistsError:
pass
try:
pid = int(pidfile.read_text())
if pid == mypid:
if self.pidfile and self.pidfile != pidfile and self.pidfile.exists():
self.pidfile.unlink()
return None
if pid_exists(pid):
raise FileExistsError(f'{name} is already edited by process {pid}')
pidfile.unlink()
except FileNotFoundError:
pass
time.sleep(itry * 0.01)
raise RuntimeError('pidfile error: too many tries')
def run(self):
try:
self.pidfile.write_text(str(os.getpid()))
super().run(Writer)
super().run()
except Exception:
print(formatExtendedTraceback())
finally:
self.pidfile.unlink()
if self.pidfile:
self.pidfile.unlink()
if __name__ == "__main__":

View File

@@ -24,11 +24,14 @@ import frappy
from pathlib import Path
from ast import literal_eval
from importlib import import_module
from frappy.config import process_file, Node
from frappy.config import process_file, Node, fix_io_modules
from frappy.core import Module
from frappy.datatypes import DataType
HEADER = "# please edit this file with frappy edit"
class Site:
domain = 'psi.ch'
frappy_subdir = 'frappy_psi'
@@ -167,11 +170,8 @@ class Value:
def validate_from_string(self, strvalue):
self.strvalue = strvalue
self.value = self.callback(self.datatype.from_string(strvalue))
if type(self.datatype).__name__ == 'ClassCompletion':
raise ValueError(self)
def set_from_string(self, strvalue):
self.strvalue = strvalue
try:
self.validate_from_string(strvalue)
except Exception as e:
@@ -190,7 +190,7 @@ class Value:
return repr(self.strvalue)
def __repr__(self):
return f'Value({self.value!r}, {self.datatype!r})'
return f'{type(self).__name__}({self.value!r}, {self.datatype!r})'
def get_datatype(pname, cls, value):
@@ -259,7 +259,6 @@ class ClassChecker:
error = None
else:
error = 'empty element'
if error:
self.name = name
self.error = error
@@ -300,6 +299,8 @@ class ModuleClass(DataType):
checker = ClassChecker(value)
if checker.error:
raise ValueError(checker.error)
if checker.clsobj is None:
raise ValueError(value)
return checker.clsobj
@classmethod
@@ -343,9 +344,15 @@ def moddata_from_cfgfile(name, cls, **kwds):
def moddata_to_py(name, cls, description, **kwds):
if '<' in cls.get_repr():
raise ValueError(cls)
items = [f'Mod({name!r}', cls.get_repr(), description.get_repr()]
if cls.strvalue == '<auto>':
uri = kwds.pop('uri')
items = [f'IO({name!r}, {uri.strvalue!r}']
if description.strvalue:
items.append(f'description={description.strvalue!r}')
else:
if '<' in cls.get_repr():
raise ValueError(cls)
items = [f'Mod({name!r}', cls.get_repr(), description.get_repr()]
paramdict = {}
for name, valobj in kwds.items():
param, _, prop = name.partition('.')
@@ -364,6 +371,8 @@ def moddata_to_py(name, cls, description, **kwds):
# extend with keyworded values for parameter properties
args.extend(f'{k}={v.get_repr()}' for k, v in props.items())
items.append(f"{name} = Param({', '.join(args)})")
if len(items) == 1:
return f"{items[0]})"
items.append(')')
return ',\n '.join(items)
@@ -403,8 +412,8 @@ def nodedata_from_cfgfile(name, equipment_id='', description='', interface='', c
return {k: Value(v) for k, v in props.items()}
def nodedata_to_py(name, equipment_id, title, doc, interface=None, cls=None, **kwds):
eq_id = fix_equipment_id(name.value, equipment_id.value)
def nodedata_to_py(name, title, doc, equipment_id=None, interface=None, cls=None, **kwds):
eq_id = fix_equipment_id(name.value, equipment_id.value if equipment_id else '')
intfc = site.default_interface if interface is None else interface.value
desc = title.value.strip()
doc = doc.value.strip()
@@ -425,18 +434,57 @@ def cfgdata_to_py(node, **moddata):
"""convert cfgdata to python code
:param node: dict <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
"""
items = [nodedata_to_py(**node)] + [moddata_to_py(k, **v) for k, v in moddata.items()]
items = [HEADER, nodedata_to_py(**node)] + [moddata_to_py(k, **v) for k, v in moddata.items()]
return '\n\n'.join(items)
def cfgdata_from_py(name, cfgpath, filecontent, logger):
if filecontent:
config = process_file(cfgpath, logger, filecontent)
iodict = {k: v for k, v in config.items() if v.get('cls') == '<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:
config = {}
nodecfg = config.pop('node', {})
modcfg = {k: moddata_from_cfgfile(k, **v) for k, v in config.items()}
return nodedata_from_cfgfile(name, **nodecfg), modcfg
iodict = {}
nodecfg = {}
errors = {}
for modname, modcfg in config.items():
modio = modcfg.get('io')
if modio: # convert legacy io Mod cfg to IO
ioclass = None
try:
ioname = modio['value']
if ioname in iodict:
continue
iomodcfg = config[ioname]
if set(iomodcfg) - {'uri', 'cls', 'description'}:
continue
iomodcls = iomodcfg['cls']
modcls = modcfg['cls']
ioclass = f"{modcls}.ioClass"
if ModuleClass.validate(iomodcls) != ModuleClass.validate(ioclass):
continue
iomodcfg['cls'] = '<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
class KEY:
"""helper class for keys
converts all keys used into an instance of Key
"""
# unfortunately, we can not use some ctrl keys, as they already have a meaning:
# ^H: backspace, ^I: tab, ^M: ret, ^S/^Q: flow control
ESC = 27
TAB = 9
DEL = 127
RETURN = 13
QUIT = '^q'
BEG_LINE = '^a'
END_LINE = '^e'
MENU = '^x'
CUT = '^k'
PASTE = '^v'
HELP = '^g'
UNHANDLED = -1
GOTO_MAIN = -2
GO_UP = -3
UP = None # get curses.KEY_UP
DOWN = None
LEFT = None
RIGHT = None
ENTER = None
bynumber = {k: chr(k) for k in range(32, 127)}
byname = {}
@classmethod
def init(cls):
for name in dir(cls):
if name.isupper():
cls.add_key(name, getattr(cls, name))
for base in cls.__mro__:
for name in getattr(base, 'from_curses', ()):
cls.add_key(name, getattr(curses, f'KEY_{name}'))
@classmethod
def add_key(cls, name, nr):
if isinstance(nr, str):
assert nr[0] == '^'
nr = ord(nr[1]) & 0x1f
elif nr is None:
nr = getattr(curses, f'KEY_{name}')
cls.byname[name] = cls.bynumber[nr] = key = Key(name, nr)
setattr(cls, name, key)
@classmethod
def add(cls, **kwds):
for name, nr in kwds.items():
cls.add_key(name, nr)
class Key(int):
def __new__(cls, name, nr):
if isinstance(nr, str):
@@ -33,41 +87,18 @@ class Key(int):
key.name = name
return key
def __repr__(self):
def short(self):
"""as __repr__, but ctrl keys are translated to ^<letter>"""
if 0 <= self < 32:
if 0 < self <= 26:
return f'^{chr(96 + self)}'
return f'^{chr(64 + self)}'
return self.name
class Keys:
def __init__(self, **kwds):
# mapping int -> letter
self.bynumber = {k: chr(k) for k in range(32, 127)}
self.keys = {}
self.add(**kwds)
def __repr__(self):
"""name by function"""
return self.name
def add(self, **kwds):
toadd = [(k, Key(k, v)) for k, v in kwds.items()]
self.keys.update(toadd)
self.bynumber.update((int(v), v) for _, v in toadd)
def __getattr__(self, name):
try:
return self.keys[name]
except KeyError:
nr = getattr(curses, f'KEY_{name}', None)
if nr is None:
raise AttributeError(f'no key K.{name} {self.keys}')
self.keys[name] = key = Key(name, nr)
return key
# unfortunately, we can not use some ctrl keys, as they already have a meaning:
# H: backspace, I: tab, M: ret
K = Keys(ESC=27, TAB=9, DEL=127, RETURN=13,
QUIT='^x', BEG_LINE = '^a', END_LINE = '^e', MENU = '^c',
CUT = '^k', PASTE = '^v', HELP = '^g',
UNHANDLED=-1, GOTO_MAIN=-2, GO_UP=-3,
)
def clamp(*args):
return sorted(args)[len(args) // 2]
@@ -100,6 +131,7 @@ class Widget:
default_height = 1
log = Logger()
context_menu = None
default_width = 21
def get_menu(self):
if self.context_menu:
@@ -118,10 +150,13 @@ class Widget:
"""returns current row"""
return 0
def height(self):
def height(self, to_focus=None):
"""returns current height"""
return self.default_height
def width(self):
return self.default_width
# def draw(self, wr, in_focus=False):
# raise NotImplementedError
@@ -131,23 +166,22 @@ class HasWidgets:
widgets = None # list of subwidgets
def current_row(self):
height = sum(w.height() for w in self.widgets[:self.focus])
return height + self.get_focus_widget().current_row()
return self.height(self.focus) + self.get_focus_widget().current_row()
def height(self):
return sum(w.height() for w in self.widgets)
def height(self, to_focus=None):
if to_focus is None:
to_focus = len(self.widgets)
return sum(w.height() for w in self.widgets[:to_focus])
def handle(self, main):
try:
while True:
main.current_widget = self.get_focus_widget()
key = main.current_widget.handle(main)
# if key in (CTRL_X, K.LEFT):
# return key
if key == K.UP:
if key == KEY.UP:
if self.advance(-1):
continue
elif key in (K.DOWN, K.RETURN, K.ENTER):
elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER):
if self.advance(1):
continue
return key
@@ -199,17 +233,20 @@ class Container(HasWidgets, Widget):
class TitleBar(Widget):
default_height = 1
def __init__(self, left, right=''):
def __init__(self, left, mid='', right=''):
self.left = left
self.mid = mid
self.right = right
def draw(self, wr, in_focus=False):
left, mid, right, wid = self.left, self.mid, self.right, wr.width - 1
midleft = (wid - len(mid) + 1) // 2
if len(left) < midleft and midleft + len(mid) + len(right) < wid:
text = (left.ljust(midleft) + mid).ljust(wid - len(right)) + right
else:
text = left.ljust(wid - len(right)) + right
wr.startrow()
text = self.left.ljust(wr.width)
wr.bar(text)
if self.right:
wr.col = wr.width - len(self.right) - 3
wr.bar(f' {self.right} ')
wr.bar(text + ' ')
class StatusBar(Widget):
@@ -233,7 +270,6 @@ class StatusBar(Widget):
else:
wr.wr(self.text[:wid].rjust(wid) + ' ', wr.barstyle)
def set(self, text, warn=None, query=False):
self.text = text
self.warn = warn
@@ -257,6 +293,9 @@ class TextEdit(Widget):
self.col_offset = 0
self.finish_callback = finish_callback
def width(self):
return max(self.minwidth, len(self.value))
def draw(self, wr, in_focus=False):
text = self.value[self.col_offset:]
if in_focus:
@@ -291,22 +330,22 @@ class TextEdit(Widget):
try:
while True:
key = self.get_key(main)
if key == K.LEFT:
if key == KEY.LEFT:
if self.highlighted:
return key
self.pos = max(0, self.pos - 1)
continue
if key == K.TAB:
if key == KEY.TAB:
self.highlighted = not self.highlighted
self.pos = 0
continue
if key == K.BEG_LINE:
if key == KEY.BEG_LINE:
self.pos = 0
elif key == K.END_LINE:
elif key == KEY.END_LINE:
self.pos = len(self.value)
elif key == K.RIGHT:
elif key == KEY.RIGHT:
self.pos = len(self.value) if self.highlighted else min(len(self.value), self.pos + 1)
elif key == K.DEL:
elif key == KEY.DEL:
if self.highlighted:
self.value = ''
self.pos = 0
@@ -319,9 +358,9 @@ class TextEdit(Widget):
self.value = self.value[:self.pos] + key + self.value[self.pos:]
self.pos += 1
elif key is not None:
if key == K.ESC:
if key == KEY.ESC:
save = False
return K.DOWN
return KEY.DOWN
return key
self.highlighted = False
finally:
@@ -344,8 +383,8 @@ class TextEdit(Widget):
class NameEdit(TextEdit):
def get_key(self, main):
key = super().get_key(main)
if key == K.TAB:
key = K.ENTER
if key == KEY.TAB:
key = KEY.ENTER
return key
@@ -368,11 +407,6 @@ class TextEditCompl(TextEdit):
self.completion_pos = 0
self.completion = completion
def finish(self, value, main):
result = super().finish(value, main)
self.log.info('finish %r %r', value, result)
return result
def enter(self, main):
main.popupmenu = None
@@ -397,35 +431,39 @@ class TextEditCompl(TextEdit):
self.get_selection_menu(main, self.value)
key = super().get_key(main)
self.menu = menu = main.popupmenu
if not menu or self.highlighted or key != K.DOWN or self.pos < self.completion_pos:
if not menu or self.highlighted or key != KEY.DOWN or self.pos < self.completion_pos:
self.menu = main.popupmenu = None
return key
menu.pos = 1
key = menu.get_key(main)
# we have left the popup menu with returned key
if key == K.UP:
if key == KEY.UP:
return None
if key == K.LEFT:
if key == KEY.LEFT:
self.pos = self.completion_pos
return key
value = self.value[:self.completion_pos]
if key in (K.TAB, K.RETURN, K.ENTER, K.RIGHT):
selected = menu.get_value()
if key in (KEY.TAB, KEY.RETURN, KEY.ENTER, KEY.RIGHT):
selected = menu.get_value()
value += selected
if key == K.TAB or (key in (K.ENTER, K.RETURN)
if key == KEY.TAB or (key in (KEY.ENTER, KEY.RETURN)
and self.completion(value)[0] < len(value)):
key = K.RIGHT
key = KEY.RIGHT
self.value = value
self.pos = len(value)
self.menu = main.popupmenu = None
return key
class TextWidget(Widget):
# minwidth = 16
def __init__(self, text):
self.value = text
def width(self):
return len(self.value)
def draw(self, wr, in_focus=False):
wr.norm(self.value)
@@ -438,6 +476,9 @@ class LineEdit(Widget):
self.valuewidget = valuewidget
self.focus = 1
def width(self):
return self.labelwidget.width() + 2 + self.valuewidget.width()
def handle(self, main):
name = self.labelwidget.value if isinstance(self.labelwidget, TextEdit) else None
if name == '':
@@ -445,14 +486,14 @@ class LineEdit(Widget):
while True:
if self.focus:
key = self.valuewidget.handle(main)
if key == K.LEFT:
if key == KEY.LEFT:
if name is not None:
self.focus = 0
continue
return key
key = self.labelwidget.handle(main)
self.focus = 1
if key in (K.ENTER, K.RETURN, K.TAB):
if key in (KEY.ENTER, KEY.RETURN, KEY.TAB):
key = None
return key
@@ -478,8 +519,10 @@ class MultiLineEdit(Container):
self.validator = validator
self.highlighted = False
self.cut_menuitems = [
MenuItem('cut line(s)', K.CUT),
MenuItem('paste line(s)', K.PASTE)]
MenuItem('cut line(s)', KEY.CUT),
MenuItem('paste line(s)', KEY.PASTE),
MenuItem('go to end of line', KEY.END_LINE),
MenuItem('back to start of line', KEY.BEG_LINE)]
def get_menu(self):
return self.cut_menuitems + self.parent.get_menu()
@@ -507,10 +550,10 @@ class MultiLineEdit(Container):
self.highlighted = True
key = main.get_key()
self.highlighted = False
if key in (K.RIGHT, K.TAB):
if key in (KEY.RIGHT, KEY.TAB):
self.set_focus(None, -1)
self.next_key = K.RIGHT
elif isinstance(key, str) or key == K.DEL:
self.next_key = KEY.RIGHT
elif isinstance(key, str) or key == KEY.DEL:
self.set_focus(None, -1)
last = self.get_focus_widget()
if self.focus == 0:
@@ -520,21 +563,21 @@ class MultiLineEdit(Container):
self.widgets.append(LineOfMultiline(self, ''))
self.advance(1)
self.next_key = key
elif key == K.LEFT:
elif key == KEY.LEFT:
self.set_focus(None, 0)
self.next_key = K.LEFT
self.next_key = KEY.LEFT
else:
return key
while True:
widget = self.get_focus_widget()
key = widget.handle(main)
if key == K.UP:
if key == KEY.UP:
if self.advance(-1):
continue
elif key in (K.DOWN, K.RETURN, K.ENTER):
elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER):
if self.advance(1):
continue
elif key == K.GO_UP:
elif key == KEY.GO_UP:
continue
return key
@@ -564,12 +607,12 @@ class LineOfMultiline(TextEdit):
else:
self.parent.next_key = None
multiline = self.parent
if key == K.RETURN:
if key == KEY.RETURN:
self.value, nextline = self.value[:self.pos], self.value[self.pos:]
multiline.widgets.insert(multiline.focus + 1, LineOfMultiline(multiline, nextline))
main.cursor_col = 0
return K.DOWN
if key == K.DEL:
return KEY.DOWN
if key == KEY.DEL:
if self.pos == 0:
if multiline.focus > 0:
thisline = self.value
@@ -578,21 +621,19 @@ class LineOfMultiline(TextEdit):
pos = len(prev.value)
prev.value += thisline
main.cursor_col = pos
return K.UP
elif key == K.LEFT:
return KEY.UP
elif key == KEY.LEFT:
if self.pos == 0 and multiline.focus > 0:
prev = multiline.widgets[multiline.focus - 1]
main.cursor_col = len(prev.value)
# self.log.info('LEFT %r prev=%r', main.cursor_col, prev.value)
return K.UP
elif key == K.RIGHT:
return KEY.UP
elif key == KEY.RIGHT:
if self.pos == len(self.value) and multiline.focus < len(multiline.widgets)-1:
# self.log.info('RIGHT %r focus=%r', main.cursor_col, multiline.focus)
main.cursor_col = 0
return K.DOWN
elif key in (K.UP, K.DOWN):
return KEY.DOWN
elif key in (KEY.UP, KEY.DOWN):
return key
elif key == K.CUT:
elif key == KEY.CUT:
if not main.cut_lines:
main.cut_extend = False
if not main.cut_extend:
@@ -602,12 +643,11 @@ class LineOfMultiline(TextEdit):
multiline.widgets[i:i+1] = []
main.cut_extend = True
main.status(f'{len(main.cut_lines)} lines buffered')
return K.GO_UP
elif key == K.PASTE:
return KEY.GO_UP
elif key == KEY.PASTE:
multiline.widgets[multiline.focus: multiline.focus] = [
LineOfMultiline(multiline, v) for v in main.cut_lines]
return K.GO_UP
# self.log.info('SET COL %r', self.pos)
return KEY.GO_UP
self.cursor_col = self.pos
return key
@@ -636,9 +676,9 @@ class PopUpMenu:
def get_key(self, main):
while True:
key = main.get_key()
if key == K.DOWN:
if key == KEY.DOWN:
self.advance(1)
elif key == K.UP:
elif key == KEY.UP:
self.advance(-1)
else:
return key
@@ -661,7 +701,7 @@ class CompletionMenu(PopUpMenu):
height = self.height + 1
if self.pos == 0:
# rectangle is open on top
wr.rectangle(row - self.pos - 1, col, height, self.width + 1, row + 1)
wr.rectangle(row - self.pos - 1, col, height, self.width + 1, top=row + 1)
else:
wr.rectangle(row - self.pos - 1, col, height, self.width + 1)
for i, value in enumerate(self.selection):
@@ -673,6 +713,113 @@ class CompletionMenu(PopUpMenu):
wr.menu(value.ljust(self.width))
class DialogInput(Widget):
"""this is used as part of a modal dialog
pressing return will leave the modal dialog
"""
def __init__(self, parent, label, value):
self.init_parent(parent, ModalDialog)
self.label = label
self.valuewidget = TextEdit(value)
self.result = None
def get_value(self):
return self.valuewidget.value
def draw(self, wr, in_focus=False):
# gap = wr.width - self.width() - 2 - wr.left
# if gap > 0:
# self.valuewidget.minwidth = self.valuewidget.width() + gap
wr.startrow()
wr.menu(f'{self.label}: ')
self.valuewidget.draw(wr, in_focus)
def width(self):
return len(self.label) + 2 + self.valuewidget.width()
def handle(self, main):
prev_cursor = main.cursor_pos
try:
key = self.valuewidget.handle(main)
if key in (KEY.RETURN, KEY.ENTER):
self.result = self.valuewidget.value
return KEY.GOTO_MAIN
return key
finally:
main.cursor_pos = prev_cursor
class PushButton(Widget):
def __init__(self, parent, label, arg, startline=True):
super().init_parent(parent, ModalDialog)
self.label = label
self.arg = arg
self.startline = startline
def width(self):
return len(self.label) + (0 if self.startline else 1)
def height(self):
return 1 if self.startline else 0
def draw(self, wr, in_focus=False):
if self.startline:
wr.startrow()
else:
wr.menu(' ')
if in_focus:
wr.high(self.label)
else:
wr.button(self.label)
def handle(self, main):
key = main.get_key()
if key in (KEY.RETURN, KEY.ENTER, 'y', 'Y'):
self.parent.result = self.arg
return KEY.GOTO_MAIN
# if key == KEY.LEFT:
# return KEY.UP
# if key == KEY.RIGHT:
# return KEY.DOWN
return key
class ModalDialog(Container):
result = None
def __init__(self, widgets):
self.widgets = widgets
self.calc_size()
def calc_size(self):
self.height = sum(w.height() for w in self.widgets)
def execute(self, main):
try:
main.popupmenu = self
while True:
key = self.handle(main)
if key in (KEY.GOTO_MAIN, KEY.QUIT):
return key
finally:
main.popupmenu = None
def get_menu(self):
return None
def draw(self, wr, in_focus=True):
wr.move(wr.row, 1)
self.calc_size()
wr.rectangle(wr.row - 1, wr.col - 1, self.height + 1, wr.width - 1, wr.menustyle)
prevrow = wr.nextrow
for _ in range(self.height):
wr.startrow()
wr.menu(' ' * (wr.width - 2))
wr.nextrow = prevrow
self.draw_widgets(wr, in_focus)
class ConfirmDialog(Widget):
def __init__(self, query, positive_answers=('Y', 'y')):
self.query = query
@@ -697,7 +844,7 @@ class ConfirmDialog(Widget):
class MenuItem:
def __init__(self, name, shortcut=None, action=None, returnkey=K.GOTO_MAIN):
def __init__(self, name, shortcut=None, action=None, returnkey=KEY.GOTO_MAIN):
"""a menu item for a context menu
:param name: the displayed command name
@@ -710,7 +857,7 @@ class MenuItem:
self.shortcut = shortcut or ''
if shortcut:
if isinstance(shortcut, Key):
self.shortcut = f'^{chr(shortcut+96)}'
self.shortcut = shortcut.short()
key = shortcut
letter = chr(96 + shortcut)
self.keys = (shortcut, letter)
@@ -724,7 +871,6 @@ class MenuItem:
self.returnkey = returnkey
else:
self.returnkey = key
self.width = len(name)
def do_action(self):
if self.action:
@@ -743,14 +889,13 @@ class ContextMenu(PopUpMenu):
"""
self.menuitems = [MenuItem('')] + menuitems
self.height = len(self.menuitems)
self.width = max(v.width for v in menuitems)
# self.selection = [v.name for v in menuitems]
self.hotkeys = {k: m for m in menuitems for k in m.keys}
def do_hotkey(self, key):
menuitem = self.hotkeys.get(key)
if not menuitem:
return K.UNHANDLED
return KEY.UNHANDLED
return menuitem.do_action()
def do_action(self):
@@ -765,20 +910,21 @@ class ContextMenu(PopUpMenu):
wr.left = max(1, wr.left)
wr.nextrow = row = row + fixrow - toprow
col = wr.left - 1
width = max(len(v.name) for v in self.menuitems)
for i, menuitem in enumerate(self.menuitems):
wr.startrow()
wr.menu(menuitem.shortcut.ljust(2))
wr.norm(' ')
if i == self.pos:
wr.high(menuitem.name, self.width)
wr.high(menuitem.name, width)
else:
wr.menu(menuitem.name, self.width)
wr.rectangle(row, col, height - 1, self.width + 4)
wr.menu(menuitem.name, width)
wr.rectangle(row, col, height - 1, width + 4)
class BaseWriter:
"""base for writer. does nothing else than keeping track of position"""
highstyle = editstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = None
highstyle = brightstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = None
errorflag = '! '
def __init__(self):
@@ -805,7 +951,7 @@ class BaseWriter:
self.bright(text, width)
def bright(self, text, width=0):
self.wr(text.ljust(width), self.editstyle, extend_to=width)
self.wr(text.ljust(width), self.brightstyle, extend_to=width)
def high(self, text, width=0):
self.wr(text.ljust(width), self.highstyle, extend_to=width)
@@ -813,6 +959,9 @@ class BaseWriter:
def menu(self, text, width=0):
self.wr(text, self.menustyle, extend_to=width)
def button(self, text, width=0):
self.wr(text, self.buttonstyle, extend_to=width)
def error(self, text, width=0):
self.wr(f'{self.errorflag}{text}', self.errorstyle, extend_to=width)
@@ -843,9 +992,9 @@ class BaseWriter:
class Writer(BaseWriter):
highstyle = curses.A_REVERSE
menustyle = curses.A_BOLD
buttonstyle = curses.A_BOLD
barstyle = curses.A_REVERSE
editstyle = 0
brightstyle = menustyle = 0
errorstyle = 0
errorflag = '! '
newoffset = None
@@ -922,14 +1071,14 @@ class Writer(BaseWriter):
dim_white = (680, 680, 680)
stdscr.bkgd(' ', cls.make_pair(black, dim_white))
bright_white = 1000, 1000, 1000
cls.editstyle = cls.make_pair(black, bright_white)
cls.menustyle = cls.brightstyle = cls.make_pair(black, bright_white)
red = cls.make_color((680, 0, 0))
cls.errorstyle = cls.make_pair(red, dim_white)
cls.errorflag = ''
very_light_blue = (800, 900, 1000)
cls.highstyle = cls.make_pair(black, very_light_blue)
light_white = (900, 900, 900)
cls.menustyle = cls.make_pair(black, light_white)
light_white = (800, 800, 800)
cls.buttonstyle = cls.make_pair(black, light_white)
light_green = 0, 1000, 0
cls.querystyle = cls.make_pair(black, light_green)
yellow = 1000, 1000, 0
@@ -957,7 +1106,7 @@ class Writer(BaseWriter):
self.cursor_visible = visible
self.main.cursor_pos = self.row - self.offset, self.col + pos
def vline(self, row, col, length, top, bottom, left, right):
def vline(self, row, col, length, top, bottom, left, right, *attr):
"""draw a vertical line
:param row, col: upper start point
@@ -978,10 +1127,10 @@ class Writer(BaseWriter):
length -= top - beg
beg = None
if length > 0:
self.scr.vline(row, col, curses.ACS_VLINE, length)
self.scr.vline(row, col, curses.ACS_VLINE, length, *attr)
return beg, end
def hline(self, row, col, length, top, bottom, left, right):
def hline(self, row, col, length, top, bottom, left, right, *attr):
"""draw a horizontal line
:param row, col: left start point
@@ -1002,36 +1151,34 @@ class Writer(BaseWriter):
length -= left - beg
beg = None
if length > 0:
self.scr.hline(row, col, curses.ACS_HLINE, length)
self.scr.hline(row, col, curses.ACS_HLINE, length, *attr)
else:
raise ValueError(length)
return None, None
if end is None and beg is not None:
raise ValueError('end None')
return beg, end
def rectangle(self, row, col, height, width, top=None, bottom=None, left=None, right=None):
def rectangle(self, row, col, height, width, *attr, **clip):
"""clipped rectangle"""
row = row - self.offset
clip = (
max(0, (top or 0) - self.offset),
self.height if bottom is None else min(self.height, bottom - self.offset),
max(0, (left or 0)),
self.width if right is None else min(self.width, right)
)
self.vline(row, col, height, *clip)
self.vline(row, col + width, height, *clip)
left, right = self.hline(row, col, width, *clip)
args = (
max(0, clip.get('top', 0) - self.offset),
min(self.height, clip.get('bottom', self.height + self.offset) - self.offset),
max(0, clip.get('left', 0)),
min(self.width, clip.get('right', self.width))
) + attr
self.vline(row, col, height, *args)
self.vline(row, col + width, height, *args)
left, right = self.hline(row, col, width, *args)
if left is not None:
self.scr.addch(row, left, curses.ACS_ULCORNER)
self.scr.addch(row, left, curses.ACS_ULCORNER, *attr)
if right is not None:
self.scr.addch(row, right, curses.ACS_URCORNER)
self.scr.addch(row, right, curses.ACS_URCORNER, *attr)
row += height
left, right = self.hline(row, col, width, *clip)
left, right = self.hline(row, col, width, *args)
if left is not None:
self.scr.addch(row, left, curses.ACS_LLCORNER)
self.scr.addch(row, left, curses.ACS_LLCORNER, *attr)
if right is not None:
self.scr.addch(row, right, curses.ACS_LRCORNER)
self.scr.addch(row, right, curses.ACS_LRCORNER, *attr)
class Empty(Widget):
@@ -1042,20 +1189,27 @@ class Empty(Widget):
HELP_TEXT = """
ctrl-X Exit
ctrl-C context menu
ctrl-X Context Menu
ctrl-Q Quit
"""
class Main(HasWidgets):
parent = None
popupmenu = None
statusbar = None
menubar = None
context_menu = None
help_text = HELP_TEXT
log = Widget.log
leftwidth = 0.2 # if < 1: a fraction
def __init__(self, widgets, /, headers=(), footers=()):
def __init__(self, widgets, writercls, headers=(), footers=()):
#KEY.add(
# ESC=27, TAB=9, DEL=127, RETURN=13,
# QUIT='^x', BEG_LINE='^a', END_LINE='^e', MENU='^c', CUT='^k', PASTE='^v', HELP='^g',
# UNHANDLED=-1, GOTO_MAIN=-2, GO_UP=-3)
self.writercls = writercls
self.focus = 0
self.headers = headers
self.footers = footers
@@ -1070,16 +1224,50 @@ class Main(HasWidgets):
self.cursor_col = 0 # position of cursor in MultiLineEdit
self.cursor_pos = (0, 0)
self.popup_offset = 0
self.quit = False
self.quit_action = None
self.cut_lines = []
self.cut_extend = False
self.context_menu = [
MenuItem('help', K.HELP, self.handle_help, None),
MenuItem('exit', K.QUIT, self.do_quit),
]
def run(self):
try:
self.scr = curses.initscr()
try:
self.writercls.init_colors(self.scr)
except Exception:
raise RuntimeError('it seems you have a terminal without colors. for a test, remove this line')
pass
curses.noecho()
curses.raw() # disable ctrl-C interrupt and ctrl-Q/S flow control
curses.nonl() # accept ctrl-J
self.scr.keypad(1)
KEY.init()
self.context_menu = [
MenuItem('help', KEY.HELP, self.handle_help, None),
MenuItem('exit', KEY.QUIT, self.do_quit),
]
while self.quit_action is None or not self.quit_action():
key = self.handle(self)
if key not in (KEY.GOTO_MAIN, None, KEY.UP, KEY.DOWN, KEY.UNHANDLED):
self.status(f'unknown key {key}')
self.finish(None)
except Exception as e:
self.finish(e)
raise
finally:
if self.scr:
self.scr.keypad(0)
curses.echo()
curses.nocbreak()
curses.endwin()
for logline in self.log.loglines:
print(logline)
def do_quit(self):
self.quit = True
self.quit_action = self.quit
return KEY.GOTO_MAIN
def quit(self):
pass
def get_topmargin(self):
return sum(v.height() for v in self.headers)
@@ -1098,46 +1286,40 @@ class Main(HasWidgets):
key = self.scr.getch()
else:
continue
key = K.bynumber.get(key, key)
key = KEY.bynumber.get(key, key)
self.status(None)
if isinstance(key, str):
self.log.info('key letter %r', key)
self.cut_extend = False
return key
if 0 <= key < 32: # control keys
self.log.info('key ctrl %r', key)
if key != K.CUT:
if key != KEY.CUT:
self.cut_extend = False
self.log.info('extend False')
menu = ContextMenu(self.current_widget.get_menu())
if key == K.MENU:
try:
if self.popupmenu:
return key
self.popupmenu = menu
key = menu.get_key(self)
if key == K.MENU:
menuitems = self.current_widget.get_menu()
if menuitems and not self.popupmenu:
menu = ContextMenu(menuitems)
if key == KEY.MENU:
try:
if self.popupmenu:
return key
self.popupmenu = menu
key = menu.get_key(self)
if key == KEY.MENU:
continue
if key in (KEY.TAB, KEY.RETURN, KEY.ENTER):
return menu.do_action()
key = menu.do_hotkey(key)
if key != KEY.UNHANDLED:
return key
finally:
self.popupmenu = None
result = menu.do_hotkey(key)
if result != KEY.UNHANDLED:
if result is None:
continue
if key in (K.TAB, K.RETURN, K.ENTER):
return menu.do_action()
key = menu.do_hotkey(key)
if key != K.UNHANDLED:
self.log.info('got key %r', key)
return key
self.status(f'can not handle key {key}')
raise ValueError(menu.menuitems)
finally:
self.popupmenu = None
result = menu.do_hotkey(key)
if result != K.UNHANDLED:
if result is None:
continue
return result
else:
if key != K.GOTO_MAIN:
if self.cut_extend:
self.log.info('extend False %r', key)
if key != KEY.GOTO_MAIN:
self.cut_extend = False
self.log.info('key special %r', key)
return key
def handle_help(self):
@@ -1145,10 +1327,10 @@ class Main(HasWidgets):
self.popupmenu = None
key = self.get_key()
self.help_mode = False
return key if key == K.QUIT else None
return key if key == KEY.QUIT else None
def persistent_status(self):
return 'ctrl-C: context menu/get help ctrl-X: exit'
return 'ctrl-X: context menu/help ctrl-Q: exit'
def current_row(self):
if self.help_mode:
@@ -1216,34 +1398,4 @@ class Main(HasWidgets):
self.statusbar.set(f'{text} {st}', warn)
def finish(self, exc):
pass
def run(self, writercls):
try:
self.scr = curses.initscr()
self.writercls = writercls
try:
writercls.init_colors(self.scr)
except Exception:
raise RuntimeError('it seems you have a terminal without colors. for a test, remove this line')
pass
curses.noecho()
curses.raw() # disable ctrl-C interrupt and ctrl-Q/S flow control
curses.nonl() # accept ctrl-J
self.scr.keypad(1)
while not self.quit:
key = self.handle(self)
if key not in (K.GOTO_MAIN, None, K.UP, K.DOWN):
self.status(f'unknown key {key}')
self.finish(None)
except Exception as e:
self.finish(e)
raise
finally:
if self.scr:
self.scr.keypad(0)
curses.echo()
curses.nocbreak()
curses.endwin()
for logline in self.log.loglines:
print(logline)
return True