From d0dd810de138fee9544d1da22bad99dd3abee519 Mon Sep 17 00:00:00 2001 From: Markus Zolliker Date: Tue, 3 Mar 2026 16:33:21 +0100 Subject: [PATCH] editcurses: improvements while pushing to gerrit not yet merged Change-Id: Ia8ba4feedb78f008052910f06e1946ade03b853a --- frappy/editcurses/cfgedit.py | 186 ++++++-------- frappy/editcurses/configdata.py | 57 +++-- frappy/editcurses/terminalgui.py | 424 ++----------------------------- 3 files changed, 130 insertions(+), 537 deletions(-) diff --git a/frappy/editcurses/cfgedit.py b/frappy/editcurses/cfgedit.py index c0bd9833..2205d5a8 100644 --- a/frappy/editcurses/cfgedit.py +++ b/frappy/editcurses/cfgedit.py @@ -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 +# +# ***************************************************************************** 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- -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: diff --git a/frappy/editcurses/configdata.py b/frappy/editcurses/configdata.py index 427218c4..32c9a918 100644 --- a/frappy/editcurses/configdata.py +++ b/frappy/editcurses/configdata.py @@ -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'] = '' 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: diff --git a/frappy/editcurses/terminalgui.py b/frappy/editcurses/terminalgui.py index 09e7f319..73f95261 100644 --- a/frappy/editcurses/terminalgui.py +++ b/frappy/editcurses/terminalgui.py @@ -18,86 +18,10 @@ # Markus Zolliker # # ***************************************************************************** +# 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 ^""" - 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: , (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: , (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: