editcurses: improvements while pushing to gerrit

not yet merged

Change-Id: Ia8ba4feedb78f008052910f06e1946ade03b853a
This commit is contained in:
2026-06-09 14:21:20 +02:00
parent 22ff23bdb7
commit d0dd810de1
3 changed files with 130 additions and 537 deletions
+74 -112
View File
@@ -1,7 +1,26 @@
# *****************************************************************************
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Module authors:
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
import sys
import os
import time
from subprocess import Popen, PIPE
from pathlib import Path
from psutil import pid_exists
import frappy
@@ -15,7 +34,8 @@ from frappy.editcurses.configdata import Value, cfgdata_to_py, cfgdata_from_py,
from frappy.io import IOBase
import frappy.editcurses.terminalgui as tg
from frappy.editcurses.terminalgui import Main, MenuItem, TextEdit, PushButton, \
ModalDialog, KEY
ModalDialog
from frappy.editcurses.screenwriter import KEY
KEY.add(
@@ -32,30 +52,6 @@ TIMESTAMP_FMT = '%Y-%m-%d-%H%M%S'
def get_timestamp(file):
return time.strftime(TIMESTAMP_FMT, time.localtime(file.stat().st_mtime))
# TODO:
# - use also shift-Tab for level up?
def unix_cmd(cmd, *args):
out = Popen(cmd.split() + list(args), stdout=PIPE).communicate()[0]
return list(out.decode().split('\n'))
class StringValue: # TODO: unused?
error = None
def __init__(self, value, from_string=False, datatype=None):
self.strvalue = value
def set_value(self, value):
self.strvalue = value
def set_from_string(self, strvalue):
self.strvalue = strvalue
def get_repr(self):
return repr(self.strvalue)
class TopWidget:
parent_cls = Main
@@ -73,9 +69,9 @@ class Child(tg.Widget):
parent = TopWidget
def get_name(self):
return None
raise NotImplementedError
def collect(self, cfgdict):
def collect(self, result):
pass
def check_data(self):
@@ -88,6 +84,9 @@ class Child(tg.Widget):
class HasValue(Child):
clsobj = None
def get_name(self):
raise NotImplementedError
def init_value_widget(self, parent, valobj):
self.init_parent(parent)
self.valobj = valobj
@@ -143,7 +142,6 @@ class ValueWidget(HasValue, tg.LineEdit):
self.fixedname = name
else:
labelwidget = tg.NameEdit(name, self.validate_name)
# self.log.info('value widget %r %r', name, self.fixedname)
if valobj.completion:
valueedit = tg.TextEditCompl(valobj.strvalue, self.validate, valobj.completion)
else:
@@ -173,18 +171,21 @@ class ValueWidget(HasValue, tg.LineEdit):
return self.fixedname
return self.labelwidget.value
def collect(self, as_dict):
def collect(self, result):
"""collect data"""
name = self.get_name()
if name:
as_dict[name] = self.valobj
result[name] = self.valobj
def draw(self, wr, in_focus=False):
super().draw(wr, in_focus)
valobj = self.valobj
if valobj.strvalue == '':
wr.dim(valobj.datatype.to_string(valobj.default))
elif self.error:
if valobj.strvalue == '' and valobj.default is not None:
default = valobj.datatype.to_string(valobj.default)
if not in_focus:
wr.dim(default)
return
if self.error:
wr.norm(' ')
wr.error(self.error)
@@ -201,9 +202,9 @@ class DocWidget(HasValue, tg.MultiLineEdit):
def get_name(self):
return self.name
def collect(self, config):
def collect(self, result):
self.valobj.set_value(self.value)
config[self.name] = self.valobj
result[self.name] = self.valobj
class BaseWidget(TopWidget, tg.Container):
@@ -235,7 +236,6 @@ class BaseWidget(TopWidget, tg.Container):
label = self.fixed_names.get(name)
widget = ValueWidget(self, name, valobj, label)
self.widget_dict[name] = widget
# self.log.info('add widget %r: label=%r name=%r', name, label, widget.get_name())
if pos is None:
self.widgets.append(widget)
else:
@@ -257,7 +257,9 @@ class BaseWidget(TopWidget, tg.Container):
# 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('')}
modcfg = {'name': Value(''),
'cls': Value(f'{site.frappy_subdir}.', ModuleClass),
'description': Value('')}
self.insert_module(ModuleWidget(self.parent, '', modcfg), after_current)
def add_iomodule(self, after_current=False):
@@ -265,10 +267,7 @@ class BaseWidget(TopWidget, tg.Container):
self.insert_module(IOWidget(self.parent, '', modcfg), after_current)
def get_widget_value(self, key):
try:
return self.widget_dict[key].valobj.strvalue
except KeyError:
return ''
return self.widget_dict[key].valobj.strvalue
def get_name(self):
return self.get_widget_value('name')
@@ -298,14 +297,14 @@ class ModuleName(Value):
self.main = main
super().__init__(name)
def validate_from_string(self, value):
if not value:
def validate_from_string(self, strvalue):
if not strvalue:
self.strvalue = self.value = ''
raise ValueError('empty name')
if value != self.value and value in self.main.widget_dict:
if strvalue != self.value and strvalue in self.main.modules:
self.strvalue = self.value = ''
raise ValueError(f'duplicate name {value!r}')
self.value = self.strvalue = value
raise ValueError(f'duplicate name {strvalue!r}')
self.value = self.strvalue = strvalue
class ModuleWidget(BaseWidget):
@@ -321,14 +320,14 @@ class ModuleWidget(BaseWidget):
MenuItem('add module', 'm', self.add_module),
MenuItem('add io module', 'i', self.add_iomodule),
MenuItem('purge empty items', 'e', self.purge_prs),
MenuItem('add configurable items', '+', self.complete_prs),
MenuItem('add configurable items', 'a', self.complete_prs),
MenuItem('cut module', KEY.CUT, parent.cut_module),
]
self.configure_class(modulecfg.get('cls'))
for name, valobj in modulecfg.items():
self.add_widget(name, valobj)
for pname, valobj in modulecfg.items():
self.add_widget(pname, valobj)
self.widgets.append(EndLine(self))
def configure_class(self, clsvalue):
@@ -372,8 +371,6 @@ class ModuleWidget(BaseWidget):
def check_data(self):
"""check clsobj is valid and check all params and props"""
# clswidget, = self.find_widgets('cls')
# clsobj = clswidget.valobj.value
for widget in self.widgets:
widget.check_data()
@@ -393,8 +390,6 @@ class ModuleWidget(BaseWidget):
fixed_names[name] = name
if name not in names and mandatory >= only_mandatory:
valobj = Value('', *get_datatype(name, self.clsobj, ''))
if name == 'cls':
self.log.info('add needed %r', valobj)
widget = self.add_widget(name, valobj, -1)
if mandatory:
widget.error = 'please set this mandatory property'
@@ -508,9 +503,9 @@ class NodeName(Value):
self.main = main
super().__init__(name)
def validate_from_string(self, value):
def validate_from_string(self, strvalue):
try:
self.main.set_node_name(value)
self.main.set_node_name(strvalue)
except Exception:
self.value = self.strvalue = self.main.cfgname
raise
@@ -528,13 +523,13 @@ class NodeWidget(BaseWidget):
MenuItem('add parameter/property', 'p', self.new_widget),
# MenuItem('select line', '^K', self.select, None),
]
for name, valobj in nodecfg.items():
if name == 'doc':
docwidget = DocWidget(self, name, valobj)
for pname, valobj in nodecfg.items():
if pname == 'doc':
docwidget = DocWidget(self, pname, valobj)
self.widgets.append(docwidget)
self.widget_dict['doc'] = docwidget
else:
self.add_widget(name, valobj)
self.add_widget(pname, valobj)
self.widgets.append(EndLine(self))
def new_widget(self, name=''):
@@ -549,13 +544,14 @@ class NodeWidget(BaseWidget):
if self.parent.detailed or self.get_focus_widget().get_name() in self.summ_edit:
return True
focus = self.focus + step
return False
def focus_row(self, to_focus):
main = self.parent
if main.detailed:
return super().focus_row(to_focus)
height = 0
for nr, widget in enumerate(self.widgets[:to_focus]):
for widget in self.widgets[:to_focus]:
name = widget.get_name()
if name in self.summ_edit:
height += widget.height()
@@ -603,43 +599,10 @@ class SaveDialog(ModalDialog):
return None
HELP_TEXT = """
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.
"""
class EditorMain(Main):
name = 'Main'
detailed = False
tmppath = None
help_text = HELP_TEXT
version_view = 0 # current version or when > 0 previous versions (not editable)
completion_widget = None # widget currently running a thread for guesses
leftwidth = 0.15
@@ -649,8 +612,8 @@ class EditorMain(Main):
def __init__(self, cfg):
try:
self.titlebar = tg.TitleBar('Frappy Cfg Editor')
super().__init__([], tg.Writer, [self.titlebar], [tg.StatusBar(self)],
help_file=Path(frappy.__file__).parents[1] / 'resources/editcurses/help.txt')
super().__init__([], [self.titlebar], [tg.StatusBar(self)],
help_file=Path(frappy.__file__).parents[1] / 'resources/editcurses_help.txt')
# self.select_menu = MenuItem('select module', CUT_KEY)
self.version_menu = [
MenuItem('previous version', KEY.PREV_VERSION, self.prev_version),
@@ -670,7 +633,7 @@ class EditorMain(Main):
self.dirty = False
# cleanup pidfiles
for file in self.version_dir.glob('*.pid'):
pidstr = file.read_text()
pidstr = file.read_text(encoding='ascii')
if not pid_exists(int(pidstr)):
file.unlink()
cfgpath = Path(cfg)
@@ -728,7 +691,7 @@ class EditorMain(Main):
self.offset = None # recalculate offset from screen pos
self.status(None)
def get_key(self):
def get_key(self, timeout=None):
if self.dirty:
if not self.version_view:
self.save()
@@ -737,9 +700,10 @@ class EditorMain(Main):
if self.completion_widget:
key = super().get_key(0.1)
if key is None:
# may need refresh as contents may be built in background
continue
else:
key = super().get_key()
key = super().get_key(timeout)
if self.version_view:
if isinstance(key, str) or key in [KEY.DEL]:
self.status('', 'can not edit previous version')
@@ -769,7 +733,6 @@ class EditorMain(Main):
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
if self.version_view:
@@ -786,7 +749,7 @@ class EditorMain(Main):
self.widgets[self.focus:self.focus] = self.cut_modules
self.cut_modules = []
def set_node_name(self, name, cfgpath=None):
def set_node_name(self, name, init_cfgpath=None):
if name == self.cfgname:
return
if not name:
@@ -796,7 +759,7 @@ class EditorMain(Main):
self.titlebar.mid = name
versions_path = self.version_dir / f'{name}.versions'
try:
sections = versions_path.read_text().split(VERSION_SEPARATOR)
sections = versions_path.read_text(encoding='utf-8').split(VERSION_SEPARATOR)
assert sections.pop(0) == ''
except FileNotFoundError:
sections = []
@@ -805,12 +768,12 @@ class EditorMain(Main):
self.tmppath = self.version_dir / f'{name}.current'
try:
# if a file exists already, add it to the version history
filecontent = self.tmppath.read_text()
filecontent = self.tmppath.read_text(encoding='utf-8')
self.add_version(filecontent, get_timestamp(self.tmppath))
except FileNotFoundError:
pass
if cfgpath:
cfgpaths = [cfgpath]
if init_cfgpath:
cfgpaths = [init_cfgpath]
else:
try:
cfgpaths = [to_config_path(name, self.log)]
@@ -819,7 +782,7 @@ class EditorMain(Main):
cfgpaths.append(self.tmppath)
for cfgpath in cfgpaths:
try:
filecontent = cfgpath.read_text()
filecontent = cfgpath.read_text(encoding='utf-8')
self.cfgpath = cfgpath
if cfgpath != self.tmppath:
self.titlebar.mid = str(cfgpath)
@@ -848,7 +811,7 @@ class EditorMain(Main):
sep = VERSION_SEPARATOR
versions_path = self.version_dir / f'{self.cfgname}.versions'
tmpname = versions_path.with_suffix('.tmp')
with open(tmpname, 'w') as f:
with open(tmpname, 'w', encoding='utf-8') as f:
for ts, section in self.versions.items():
f.write(sep)
f.write(f'{ts}\n')
@@ -889,7 +852,6 @@ class EditorMain(Main):
def prev_version(self):
maxv = len(self.versions)
self.version_view += 1
self.log.info('back to version %r', self.version_view)
if self.version_view > maxv:
self.status('this is the oldest version')
self.version_view = maxv
@@ -935,7 +897,7 @@ class EditorMain(Main):
filename = savedialog.filename
if filename:
self.log.info('saved %r to %r', self.cfgname, filename)
Path(filename).write_text(self.filecontent)
Path(filename).write_text(self.filecontent, encoding='utf-8')
return True
return False
@@ -950,7 +912,7 @@ class EditorMain(Main):
mypid = os.getpid()
for itry in range(15):
try:
with open(pidfile, 'x') as f:
with open(pidfile, 'x', encoding='ascii') as f:
f.write(str(mypid))
if self.pidfile and self.pidfile.exists():
self.pidfile.unlink()
@@ -959,7 +921,7 @@ class EditorMain(Main):
except FileExistsError:
pass
try:
pid = int(pidfile.read_text())
pid = int(pidfile.read_text(encoding='ascii'))
if pid == mypid:
if self.pidfile and self.pidfile != pidfile and self.pidfile.exists():
self.pidfile.unlink()
@@ -975,7 +937,7 @@ class EditorMain(Main):
def run(self):
try:
super().run()
except Exception:
except BaseException:
print(formatExtendedTraceback())
finally:
if self.filecontent:
+30 -27
View File
@@ -20,15 +20,15 @@
# *****************************************************************************
import re
import frappy
import inspect
import socket
from pathlib import Path
from ast import literal_eval
from importlib import import_module
import frappy
from frappy.lib.comparestring import compare
from frappy.config import process_file, Node
from frappy.core import Module, Parameter, Property, Attached
from frappy.core import Module, Parameter, Attached
from frappy.datatypes import DataType, EnumType
from frappy.properties import Property, UNSET
@@ -67,8 +67,8 @@ class NonStringType:
"""convert from string """
try:
return literal_eval(strvalue)
except Exception:
raise ValueError('this is no python value')
except Exception as e:
raise ValueError('this is no python value') from e
def to_string(self, value):
return repr(value)
@@ -92,7 +92,7 @@ class SimpleStringType(NonStringType):
return strvalue
def to_string(self, value):
return value
return value or '' # convert None to empty string
def format_value(self, value, unit=None):
"""convert to string
@@ -160,7 +160,8 @@ class Value:
self.datatype = stringtype if isinstance(value, str) else nonstringtype
self.set_value(value)
def callback(self, value):
def callback(self, value): # pylint: disable=method-hidden
"""to be overridden"""
return value
def set_value(self, value):
@@ -195,7 +196,7 @@ class Value:
if self.datatype:
try:
return self.datatype.format_value(self.value, False)
except Exception as e:
except Exception:
pass
return repr(self.strvalue)
@@ -314,8 +315,7 @@ class ClassChecker:
class ModuleClass(DataType):
@classmethod
def validate(cls, value, previous=None):
def __call__(self, value):
if isinstance(value, type):
if issubclass(value, Module):
return value
@@ -327,22 +327,25 @@ class ModuleClass(DataType):
raise ValueError(value)
return checker.clsobj
@classmethod
def from_string(cls, strvalue):
return cls.validate(strvalue)
def from_string(self, text):
return self.validate(text)
@classmethod
def to_string(cls, value):
value = cls.validate(value)
def to_string(self, value):
value = self.validate(value)
return f'{value.__module__}.{value.__qualname__}'
@classmethod
def format_value(cls, value, unit=None):
result = repr(cls.to_string(value))
def format_value(self, value, unit=None):
result = repr(self.to_string(value))
if '<' in result:
raise ValueError(result, value)
return result
def compatible(self, other):
raise NotImplementedError
def export_datatype(self):
raise NotImplementedError
module_class = ModuleClass()
@@ -378,10 +381,10 @@ def moddata_to_py(name, cls, description, **kwds):
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('.')
for pname, valobj in kwds.items():
param, _, prop = pname.partition('.')
paramdict.setdefault(param, {})[prop or 'value'] = valobj
for name, props in paramdict.items():
for pname, props in paramdict.items():
valueitem = props.pop('value', None)
if valueitem is None:
args = []
@@ -389,12 +392,12 @@ def moddata_to_py(name, cls, description, **kwds):
args = [valueitem.get_repr()]
if not props:
# single value
items.append(f'{name} = {args[0]}')
items.append(f'{pname} = {args[0]}')
continue
# args contains value
# 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)})")
items.append(f"{pname} = Param({', '.join(args)})")
if len(items) == 1:
return f"{items[0]})"
items.append(')')
@@ -497,14 +500,14 @@ def cfgdata_from_py(name, cfgpath, filecontent, logger):
continue
try:
ioclass = f"{modcls}.ioClass"
if ModuleClass.validate(iomodcls) != ModuleClass.validate(ioclass):
if module_class.validate(iomodcls) != module_class.validate(ioclass):
continue
iomodcfg['cls'] = '<auto>'
iomodcfg.pop('description', None)
iodict[ioname] = iomodcfg
except Exception as e:
except Exception:
iomod, iocls = iomodcls.rsplit('.', 1)
mod, cls = modcls.rsplit('.', 1)
mod = modcls.rsplit('.', 1)[0]
if mod == iomod:
iomodcls = iocls
errors[ioname] = f'{ioname}: missing ioClass={iomodcls} in source code of {modcls}'
@@ -530,7 +533,7 @@ def recommended_prs(cls):
return {}
cls = checker.clsobj
result = {}
for pname, pdict in cls.configurables.items():
for pname in cls.configurables:
pr = getattr(cls, pname)
if isinstance(pr, Property):
if pname not in SKIP_PROPS:
+26 -398
View File
@@ -18,86 +18,10 @@
# Markus Zolliker <markus.zolliker@psi.ch>
#
# *****************************************************************************
# pylint: disable=too-many-lines
import sys
import curses
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):
if nr.startswith('^'):
nr = ord(nr[1]) & 0x1f
key = super().__new__(cls, nr)
key.name = name
return key
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
def __repr__(self):
"""name by function"""
return self.name
from .screenwriter import Screen, Writer, KEY, Key
# keep this file independent of frappy
@@ -169,13 +93,10 @@ class Widget:
def width(self):
return self.default_width
# def draw(self, wr, in_focus=False):
# raise NotImplementedError
class HasWidgets:
focus = 0
widgets = None # list of subwidgets
widgets = () # list of subwidgets
def current_row(self):
return self.focus_row(self.focus) + self.get_focus_widget().current_row()
@@ -261,7 +182,7 @@ class TitleBar(Widget):
else:
text = left.ljust(wid - len(right)) + right
wr.startrow()
wr.bar(text + ' ')
wr.wr(text + ' ', wr.barstyle)
class StatusBar(Widget):
@@ -640,15 +561,13 @@ class HelpWidget(MultiLineEdit):
self.readonly = False
break
self.focus = clamp(0, main.offset + step, height)
try:
super().handle_inner(main, None, self.focus)
return
finally:
if self.original != self.value:
main.popupmenu = menu = ConfirmDialog('save changes to help text?')
if menu.handle(main):
main.help_file.write_text(self.value)
main.status(f'{main.help_file} changed')
while super().handle_inner(main, None, self.focus) != KEY.QUIT:
pass
if self.original != self.value:
main.popupmenu = menu = ConfirmDialog('save changes to help text [N]?')
if menu.handle(main):
main.help_file.write_text(self.value)
main.status(f'{main.help_file} changed')
class LineOfMultiline(TextEdit):
@@ -683,7 +602,7 @@ class LineOfMultiline(TextEdit):
return KEY.DOWN
if key == KEY.DEL:
if self.pos == 0:
if multiline.focus > 0:
if 0 < multiline.focus <= len(multiline.widgets):
thisline = self.value
del multiline.widgets[multiline.focus]
prev = multiline.widgets[multiline.focus - 1]
@@ -980,277 +899,6 @@ class ContextMenu(PopUpMenu):
wr.rectangle(row, col, height - 1, width + 4)
class BaseWriter:
"""base for writer. does nothing else than keeping track of position"""
highstyle = brightstyle = errorstyle = barstyle = menustyle = querystyle = warnstyle = buttonstyle = dimstyle = None
errorflag = '! '
def __init__(self):
self.width = None
self.nextrow = 0
self.left = 0
def move(self, row, col):
self.row = self.nextrow = row
self.col = self.left = col
def startrow(self):
self.row = self.nextrow
self.col = self.left
self.nextrow = self.row + 1
def norm(self, text, width=0):
self.wr(text, extend_to=width)
def dim(self, text, width=0):
self.wr(text, self.dimstyle, extend_to=width)
def bar(self, text, width=0):
self.wr(text, self.barstyle, extend_to=width)
def edit(self, text, width, pos):
self.bright(text, width)
def bright(self, text, width=0):
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)
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)
def write_raw(self, row, text, *attr):
self.col += len(text)
def wr(self, text, *attr, extend_to=0):
"""write text on screen
:param text: the text
:param attr: attributes
:param extend_to: extend_to >= 0: fill up to given value
extend_to < 0: extend to right margin + extend_to
"""
if self.width:
limit = self.width - self.col
if limit <= 0:
return
if extend_to < 0:
limit += extend_to
text = text.ljust(limit)
else: # elif extend_to >= limit
text = text.ljust(extend_to)[:limit]
else:
text = text.ljust(extend_to)
self.write_raw(self.row, text, *attr)
class Writer(BaseWriter):
highstyle = curses.A_REVERSE
buttonstyle = curses.A_BOLD
barstyle = curses.A_REVERSE
brightstyle = menustyle = 0
errorstyle = 0
errorflag = '! '
newoffset = None
querystyle = curses.A_BOLD
warnstyle = curses.A_REVERSE
dimstyle = 0
pairs = {}
colors = {}
nextcolor = 16
lock = threading.RLock()
leftwidth = 8 # minimum left column width
def __init__(self, stdscr, main):
super().__init__()
self.main = main
self.scr = stdscr
self.scr.clear()
self.height, self.width = stdscr.getmaxyx()
self.popup = None
self.top = 0
self.bot = self.height
self.adjust_offset_to = None
self.preferred_window_row = None
self.offset = 0
def set_leftwidth(self, leftwidth):
if leftwidth < 1:
self.leftwidth = int(self.width * leftwidth + 1)
else:
self.leftwidth = leftwidth
def write_raw(self, row, text, *attr):
row = self.row - self.offset # screen row
if self.top <= row < self.bot and self.col < self.width:
col = self.col
try:
self.scr.addstr(row, self.col, text, *attr)
self.col += len(text)
except curses.error:
self.col += len(text)
if self.col < self.width or row < self.height - 1:
raise
@classmethod
def make_color(cls, rgb):
if isinstance(rgb, int):
# a color number was given
return rgb
idx = cls.colors.get(rgb)
if idx is None:
idx = cls.nextcolor
cls.nextcolor += 1
cls.colors[idx] = rgb
curses.init_color(idx, *rgb)
return idx
@classmethod
def make_pair(cls, fg, bg):
fg = cls.make_color(fg)
bg = cls.make_color(bg)
pair = cls.pairs.get((fg, bg))
if pair is not None:
return pair
idx = len(cls.pairs) + 1
curses.init_pair(idx, fg, bg)
cls.pairs[(fg, bg)] = pair = curses.color_pair(idx)
return pair
@classmethod
def init_colors(cls, stdscr):
curses.start_color()
for nr in range(cls.nextcolor):
rgb = curses.color_content(nr)
cls.colors.setdefault(rgb, nr)
black = cls.make_color((0, 0, 0))
dim_white = (680, 680, 680)
stdscr.bkgd(' ', cls.make_pair(black, dim_white))
bright_white = 1000, 1000, 1000
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 = (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
cls.warnstyle = cls.make_pair(black, yellow)
grey = 400, 400, 400
cls.dimstyle = cls.make_pair(grey, dim_white)
def edit(self, text, width, pos):
"""write text and set cursor at given pos
also scroll horizontally if cursor would be outside screen
"""
if pos is not None:
maxwidth = self.width - self.col
scrollrange = len(text) - maxwidth + 1
if scrollrange <= 0 or pos < scrollrange:
offset = 0
else:
offset = min(pos - scrollrange, scrollrange)
text = text[offset:]
if len(text) - offset < maxwidth:
text += ' '
self.set_cursor_pos(pos - offset, True)
self.bright(text, width)
def set_cursor_pos(self, pos=0, visible=False):
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, *attr):
"""draw a vertical line
:param row, col: upper start point
:param length: length without clipping
:param top, bottom, left, right: clipping
:return: <start row or None>, <end row or None> (None is returned, when clipped on this corner)
"""
beg = None
end = None
if left <= col < right and row < bottom:
end = row + length
if end >= bottom:
length -= end - bottom
end = None
beg = row
if beg < top:
row = top
length -= top - beg
beg = None
if length > 0:
self.scr.vline(row, col, curses.ACS_VLINE, length, *attr)
return beg, end
def hline(self, row, col, length, top, bottom, left, right, *attr):
"""draw a horizontal line
:param row, col: left start point
:param length: length without clipping
:param top, bottom, left, right: clipping
:return: <start row or None>, <end row or None> (None is returned, when clipped on this corner)
"""
beg = None
end = None
if top <= row < bottom and col < right:
end = col + length
if end >= right:
length -= end - right
end = None
beg = col
if beg < left:
col = left
length -= left - beg
beg = None
if length > 0:
self.scr.hline(row, col, curses.ACS_HLINE, length, *attr)
else:
return None, None
return beg, end
def rectangle(self, row, col, height, width, *attr, **clip):
"""clipped rectangle"""
row = row - self.offset
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, *attr)
if right is not None:
self.scr.addch(row, right, curses.ACS_URCORNER, *attr)
row += height
left, right = self.hline(row, col, width, *args)
if left is not None:
self.scr.addch(row, left, curses.ACS_LLCORNER, *attr)
if right is not None:
self.scr.addch(row, right, curses.ACS_LRCORNER, *attr)
class Empty(Widget):
default_height = 0
def handle(self, main):
return None
HELP_TEXT = """
ctrl-X Context Menu
ctrl-Q Quit
@@ -1267,8 +915,8 @@ class Main(HasWidgets):
log = Widget.log
leftwidth = 0.2 # if < 1: a fraction
def __init__(self, widgets, writercls, headers=(), footers=(), help_file=None):
self.writercls = writercls
def __init__(self, widgets, headers=(), footers=(), help_file=None):
self.screen = Screen()
self.focus = 0
self.headers = headers
self.footers = footers
@@ -1290,17 +938,11 @@ class Main(HasWidgets):
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(True)
KEY.init()
self.screen.init()
if not self.screen.hascolors:
self.log.info('it seems you have a terminal without colors.')
# TODO: remove this test and try out on a terminal without colors
return
self.context_menu = [
MenuItem('help', KEY.HELP, self.handle_help, KEY.GOTO_MAIN),
MenuItem('exit', KEY.QUIT, self.do_quit),
@@ -1314,11 +956,7 @@ class Main(HasWidgets):
self.finish(e)
raise
finally:
if self.scr:
self.scr.keypad(False)
curses.echo()
curses.nocbreak()
curses.endwin()
self.screen.finish()
for logline in self.log.loglines:
print(logline)
@@ -1338,15 +976,9 @@ class Main(HasWidgets):
def get_key(self, timeout=None):
while True:
self.refresh()
if timeout is None:
key = self.scr.getch()
else:
self.scr.refresh()
if select([sys.stdin], [], [], timeout)[0]:
key = self.scr.getch()
else:
continue
key = KEY.bynumber.get(key, key)
key = self.screen.get_key(timeout)
if key is None:
continue
self.status(None)
if isinstance(key, str):
self.cut_extend = False
@@ -1409,8 +1041,8 @@ class Main(HasWidgets):
return super().current_row()
def refresh(self):
with self.writercls.lock:
wr = self.writercls(self.scr, self)
with self.screen.lock:
wr = Writer(self.screen, self)
wr.set_leftwidth(self.leftwidth)
topmargin = self.get_topmargin()
botmargin = sum(v.height() for v in self.footers)
@@ -1447,11 +1079,7 @@ class Main(HasWidgets):
col += self.cursor_pos[1]
wr.move(current_row, col)
self.popupmenu.draw(wr)
if wr.cursor_visible:
self.scr.move(*self.cursor_pos)
curses.curs_set(1)
else:
curses.curs_set(0)
self.screen.set_cursor(wr.cursor_visible, *self.cursor_pos)
def status(self, text, warn=None):
if self.statusbar: