# *****************************************************************************
#
# 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>
#
# *****************************************************************************
# pylint: disable=too-many-lines
import sys
import threading
from .screenwriter import Screen, Writer, KEY, Key


# keep this file independent of frappy
def clamp(*args):
    return sorted(args)[len(args) // 2]


def mkthread(func, *args, **kwds):
    t = threading.Thread(
        name=f'{func.__module__}:{func.__name__}',
        target=func,
        args=args,
        kwargs=kwds)
    t.daemon = True
    t.start()
    return t


class Logger:
    def __init__(self):
        # self.parent = self
        self.loglines = []

    def info(self, fmt, *args):
        self.loglines.append(fmt % args)

    def debug(self, fmt, *args):
        return

    def getChild(self, *args):
        return self

    def addHandler(self, *args):
        pass

    warning = exception = error = info
    handlers = []


class Widget:
    parent = None
    parent_cls = None
    default_height = 1
    log = Logger()
    context_menu = None
    default_width = 21

    def get_menu(self):
        if self.context_menu:
            return self.context_menu + self.parent.get_menu()
        return self.parent.get_menu()

    def init_parent(self, parent, checkcls=None):
        checkcls = checkcls or self.parent_cls
        if checkcls and not isinstance(parent, checkcls):
            raise RuntimeError(f'parent of {self} is {type(parent).__name__},'
                               f' but must be {checkcls.__name__}')
        self.parent = parent
        self.log = parent.log

    def current_row(self):
        """returns current row"""
        return 0

    def height(self):
        """returns current height"""
        return self.default_height

    def width(self):
        return self.default_width


class HasWidgets:
    focus = 0
    widgets = ()  # list of subwidgets

    def current_row(self):
        return self.focus_row(self.focus) + self.get_focus_widget().current_row()

    def focus_row(self, to_focus):
        """return relative row position of focus widget"""
        return sum(w.height() for w in self.widgets[:to_focus])

    def height(self):
        """return the summed up height"""
        return self.focus_row(len(self.widgets))

    def handle(self, main):
        try:
            while True:
                main.current_widget = self.get_focus_widget()
                key = main.current_widget.handle(main)
                if key == KEY.UP:
                    if self.advance(-1):
                        continue
                elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER):
                    if self.advance(1):
                        continue
                return key
        finally:
            main.current_widget = self

    def set_focus(self, focus, step=1):
        """set focus

        :param focus: the new focus index. will be clamped when outside range.
             None: step < 0 the last possible index, step > 0 the first possible index
        :param step: positive: take next, negative: take previous (for subclasses)
        :return: focus is a valid position
        """
        if focus is None:
            if step < 0:
                self.focus = len(self.widgets) - 1
            else:
                self.focus = 0
            return True
        if focus >= len(self.widgets):
            self.focus = len(self.widgets) - 1
            return False
        if focus < 0:
            self.focus = 0
            return False
        self.focus = focus
        return True

    def get_focus_widget(self):
        return self.widgets[clamp(0, self.focus, len(self.widgets) - 1)]

    def advance(self, step):
        return self.set_focus(self.focus + step, step)

    def insert(self, y, widget):
        self.widgets.insert(y, widget)

    def draw_widgets(self, wr, in_focus=False):
        focus = self.focus if in_focus else None
        for nr, widget in enumerate(self.widgets):
            widget.draw(wr, nr == focus)


class Container(HasWidgets, Widget):
    pass


class TitleBar(Widget):
    default_height = 1

    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()
        wr.wr(text + ' ', wr.barstyle)


class StatusBar(Widget):
    default_height = 1

    def __init__(self, main):
        main.statusbar = self
        self.main = main
        self.text = ''
        self.warn = None
        self.query = False

    def draw(self, wr, in_focus=False):
        wid = wr.width - 1
        if self.warn:
            wr.startrow()
            wr.wr(self.warn[:wid].ljust(wid) + ' ', wr.warnstyle)
        wr.startrow()
        if self.query:
            wr.wr(self.text.rjust(wid)[:wid] + ' ', wr.querystyle)
        else:
            wr.wr(self.text[:wid].rjust(wid) + ' ', wr.barstyle)

    def set(self, text, warn=None, query=False):
        self.text = text
        self.warn = warn
        self.query = query

    def height(self):
        if self.warn:
            return self.default_height + 1
        return self.default_height


class TextEdit(Widget):
    minwidth = 16
    highlighted = False

    def __init__(self, value, finish_callback=None):
        self.pos = None  # cursor pos or None when not editing
        if value is None:
            raise ValueError('textedit')
        assert isinstance(value, str)
        self.value = value
        self.col_offset = 0
        self.finish_callback = finish_callback

    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:
            if self.highlighted:
                wr.set_cursor_pos()
                wr.high(text, self.minwidth)
            else:
                wr.edit(text, self.minwidth, self.pos)
        else:
            wr.norm(text)

    def get_key(self, main):
        """hook for subclasses"""
        return main.get_key()

    def enter(self, main):
        pass

    def finish(self, value, main):
        """called before saving the value"""
        if self.finish_callback:
            value = self.finish_callback(value, main)
        return value

    def handle(self, main):
        self.highlighted = True
        self.pos = 0
        self._main = main
        self.enter(main)
        self.prev_value = self.value  # for ESC
        save = True
        try:
            while True:
                key = self.get_key(main)
                if key == KEY.LEFT:
                    if self.highlighted:
                        return key
                    self.pos = max(0, self.pos - 1)
                    continue
                if key == KEY.TAB:
                    self.highlighted = not self.highlighted
                    self.pos = 0
                    continue
                if key == KEY.BEG_LINE:
                    self.pos = 0
                elif key == KEY.END_LINE:
                    self.pos = len(self.value)
                elif key == KEY.RIGHT:
                    self.pos = len(self.value) if self.highlighted else min(len(self.value), self.pos + 1)
                elif key == KEY.DEL:
                    if self.highlighted:
                        self.value = ''
                        self.pos = 0
                    elif self.pos > 0:
                        self.value = self.value[:self.pos-1] + self.value[self.pos:]
                        self.pos -= 1
                elif isinstance(key, str):
                    if self.highlighted:
                        self.value = ''
                    self.value = self.value[:self.pos] + key + self.value[self.pos:]
                    self.pos += 1
                elif key is not None:
                    if key == KEY.ESC:
                        save = False
                        return KEY.DOWN
                    return key
                self.highlighted = False
        finally:
            if save:
                self.value = self.finish(self.value, main)
            else:
                self.value = self.prev_value
            self.pos = None


class NameEdit(TextEdit):
    def get_key(self, main):
        key = super().get_key(main)
        if key == KEY.TAB:
            key = KEY.ENTER
        return key


class Completion:
    """mixin for datatype with completion"""
    def propose(self, value):
        """get proposals for completion

        :param value: a string to complete
        :return: tuple(<list of proposals>, <start position in string for proposals>, <error message
        """
        raise NotImplementedError


class TextEditCompl(TextEdit):
    menu = None
    exc_info = None

    def __init__(self, value, finish_callback, completion):
        super().__init__(value, finish_callback)
        self.completion_pos = 0
        self.completion = completion

    def enter(self, main):
        main.popupmenu = None

    def get_selection_menu(self, main, value):
        self.exc_info = None
        try:
            self.completion_pos, selection = self.completion(value)
            if (self.pos >= self.completion_pos and
                    self == main.completion_widget):  # widget has not changed
                if selection:
                    main.popup_offset = self.completion_pos - self.pos
                    main.popupmenu = CompletionMenu(selection)
                else:
                    main.popupmenu = None
                self.completion_widget = None
        except Exception:
            self.exc_info = sys.exc_info()

    def get_key(self, main):
        if self.highlighted:
            return super().get_key(main)
        main.completion_widget = self
        self.selection_thread = mkthread(self.get_selection_menu, main, self.value)
        # self.get_selection_menu(main, self.value)
        key = super().get_key(main)
        if self.exc_info:
            raise self.exc_info[1].with_traceback(self.exc_info[2])
        self.menu = menu = main.popupmenu
        if not menu or self.highlighted or key != KEY.DOWN or self.pos < self.completion_pos:
            self.menu = main.popupmenu = None
            return key
        menu.pos = 1
        key = menu.get_key(main)
        # we have left the popup menu with returned key
        if key == KEY.UP:
            return None
        if key == KEY.LEFT:
            self.pos = self.completion_pos
            return key
        value = self.value[:self.completion_pos]
        if key in (KEY.TAB, KEY.RETURN, KEY.ENTER, KEY.RIGHT):
            selected = menu.get_value()
            value += selected
            if key == KEY.TAB or (key in (KEY.ENTER, KEY.RETURN)
                                  and self.completion(value)[0] < len(value)):
                key = KEY.RIGHT
        self.value = value
        self.pos = len(value)
        self.menu = main.popupmenu = None
        return key


class TextWidget(Widget):
    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)


class LineEdit(Widget):
    def __init__(self, labelwidget, valuewidget):
        self.labelwidget = labelwidget
        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 == '':
            self.focus = 0  # go to name first when empty
        while True:
            if self.focus:
                key = self.valuewidget.handle(main)
                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 (KEY.ENTER, KEY.RETURN, KEY.TAB):
                key = None
            return key

    def draw(self, wr, in_focus=False):
        wr.startrow()
        self.labelwidget.draw(wr, in_focus and self.focus == 0)
        wr.norm(': ')
        wr.col = max(wr.col, wr.left + wr.leftwidth)
        self.valuewidget.draw(wr, in_focus and self.focus == 1)


class MultiLineEdit(Container):
    next_key = None

    def __init__(self, name, value=None, label=None, validator=str):
        self.name = name
        self.label = f'{self.name}: ' if label is None else label
        self.focus = 0
        self.widgets = [LineOfMultiline(self, v) for v in value.split('\n')]
        self.validator = validator
        self.highlighted = False
        self.cut_menuitems = [
            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()

    def draw(self, wr, in_focus=False):
        indent = None
        for widget in self.widgets:
            wr.startrow()
            if indent is None:
                label = self.label.ljust(wr.leftwidth)
                wr.norm(label)
                indent = len(label)
            else:
                wr.col += indent
            if self.highlighted:
                wr.high(widget.value, -1)
            else:
                widget.draw(wr, in_focus)

    @property
    def value(self):
        return '\n'.join(w.value for w in self.widgets)

    def handle(self, main):
        self.highlighted = True
        key = main.get_key()
        self.highlighted = False
        return self.handle_inner(main, key)

    def handle_inner(self, main, key, focus=None):
        if key in (KEY.RIGHT, KEY.TAB):
            self.set_focus(None, -1)
            self.next_key = KEY.RIGHT
        elif isinstance(key, str) or key == KEY.DEL:
            self.set_focus(None, -1)
            last = self.get_focus_widget()
            if self.focus == 0:
                # there is only one line: override
                last.value = ''
            elif last.value:  # last line is not empty: add a new one
                self.widgets.append(LineOfMultiline(self, ''))
                self.advance(1)
            self.next_key = key
        elif key == KEY.LEFT:
            self.set_focus(None, 0)
            self.next_key = KEY.LEFT
        elif focus is not None:
            self.set_focus(focus, 0)
        else:
            return key
        while True:
            widget = self.get_focus_widget()
            key = widget.handle(main)
            if key == KEY.UP:
                if self.advance(-1):
                    continue
            elif key in (KEY.DOWN, KEY.RETURN, KEY.ENTER):
                if self.advance(1):
                    continue
            elif key == KEY.GO_UP:
                continue
            return key


class HelpWidget(MultiLineEdit):
    winsize = 25

    def __init__(self, main):
        self.init_parent(main)
        if main.help_file:
            text = main.help_file.read_text()
        else:
            text = main.help_text
        self.original = text
        self.readonly = True
        self.readonly_menu = [
            MenuItem('edit help text', KEY.END_LINE),
            MenuItem('quit help', '^q'),
        ]
        self.edit_menu = [
            MenuItem('quit help', '^q'),
        ]
        super().__init__('', text, '')

    def draw(self, wr, in_focus=False):
        wr.leftwidth = 0
        self.winsize = wr.height
        super().draw(wr, True)

    def get_menu(self):
        if self.readonly:
            return self.readonly_menu
        return self.cut_menuitems + self.edit_menu

    def handle(self, main):
        self.focus = 0
        main.offset = 0
        height = len(self.widgets)
        step = self.winsize // 2
        if self.readonly:
            while True:
                key = main.get_key()
                if key == KEY.UP:
                    main.offset = max(0, main.offset - step)
                elif key == KEY.DOWN:
                    main.offset = max(0, main.offset + step)
                else:
                    if key != KEY.END_LINE:
                        return
                    self.readonly = False
                    break
                self.focus = clamp(0, main.offset + step, height)
        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):
    minwidth = -1
    change_high = None

    def __init__(self, parent, value):
        super().__init__(value)
        self.init_parent(parent, MultiLineEdit)
        self.cursor_col = None

    def handle(self, main):
        self.change_high = self.parent.highlighted
        self.cursor_col = None
        return super().handle(main)

    def get_key(self, main):
        self.highlighted = False
        if self.cursor_col is not None:
            main.cursor_col = self.cursor_col
            self.cursor_col = None
        key = self.parent.next_key
        if key is None:
            key = main.get_key()
        else:
            self.parent.next_key = None
        multiline = self.parent
        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 KEY.DOWN
        if key == KEY.DEL:
            if self.pos == 0:
                if 0 < multiline.focus <= len(multiline.widgets):
                    thisline = self.value
                    del multiline.widgets[multiline.focus]
                    prev = multiline.widgets[multiline.focus - 1]
                    pos = len(prev.value)
                    prev.value += thisline
                    main.cursor_col = pos
                    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)
                return KEY.UP
        elif key == KEY.RIGHT:
            if self.pos == len(self.value) and multiline.focus < len(multiline.widgets)-1:
                main.cursor_col = 0
                return KEY.DOWN
        elif key in (KEY.UP, KEY.DOWN):
            return key
        elif key == KEY.CUT:
            if not main.cut_lines:
                main.cut_extend = False
            if not main.cut_extend:
                main.cut_lines = []
            main.cut_lines.append(self.value)
            i = multiline.focus
            multiline.widgets[i:i+1] = []
            main.cut_extend = True
            main.status(f'{len(main.cut_lines)} lines buffered')
            return KEY.GO_UP
        elif key == KEY.PASTE:
            multiline.widgets[multiline.focus: multiline.focus] = [
                LineOfMultiline(multiline, v) for v in main.cut_lines]
            return KEY.GO_UP
        self.cursor_col = self.pos
        return key

    def enter(self, main):
        self.pos = min(main.cursor_col, len(self.value))


class PopUpMenu:
    offset = 0
    pos = 0
    height = 0

    def advance(self, step):
        pos = self.pos + step
        if 0 <= pos < self.height:
            self.pos = pos
            return True
        return False

    def draw(self, wr):
        raise NotImplementedError

    def get_key(self, main):
        while True:
            key = main.get_key()
            if key == KEY.DOWN:
                self.advance(1)
            elif key == KEY.UP:
                self.advance(-1)
            else:
                return key


class CompletionMenu(PopUpMenu):
    def __init__(self, selection):
        self.selection = selection
        self.height = len(selection)
        self.width = max(len(v) for v in self.selection)

    def get_value(self):
        return self.selection[self.pos]

    def draw(self, wr):
        row = wr.nextrow  # the edit row
        wr.left = max(1, wr.left)
        col = wr.left - 1
        height = self.height + 1
        wr.nextrow -= self.pos
        if self.pos == 0:
            # rectangle is open on top
            wr.rectangle(row - 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):
            wr.startrow()
            if i == self.pos:
                if self.pos:
                    wr.high(value.ljust(self.width))
            else:
                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):
        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
        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
        self.answers = positive_answers
        self.width = len(query)

    def draw(self, wr):
        col = wr.col - 1
        wr.high(self.query)
        wr.rectangle(wr.row - 1, col, 2, self.width + 1)

    def handle(self, main):
        try:
            main.refresh()
            key = Main.get_key(main)
            if key in self.answers:
                return key
            self.key = key
            return False
        finally:
            main.popupmenu = None


class MenuItem:
    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
        :param shortcut: a letter. if '^' is preceeded, it means a control-char
        :param action: the action to be performed. when None, the control key (or letter) is returned
        :param returnkey: key to return after action. ignored if action is None
        """
        self.name = name
        self.action = action
        self.shortcut = shortcut or ''
        if shortcut:
            if isinstance(shortcut, Key):
                self.shortcut = shortcut.short()
                key = shortcut
                letter = chr(96 + shortcut)
                self.keys = (shortcut, letter)
            else:
                self.keys = tuple(shortcut)
                key = shortcut[0]
        else:
            self.keys = ()
            key = None
        if action:
            self.returnkey = returnkey
        else:
            self.returnkey = key

    def do_action(self):
        if self.action:
            self.action()
        return self.returnkey

    def __repr__(self):
        return f'MenuItem({self.name})'


class ContextMenu(PopUpMenu):
    def __init__(self, menuitems):
        """creates a context menu

        :param menuitems: list of (<command name>, <shortcut>, <action function>)
        """
        self.menuitems = [MenuItem('')] + menuitems
        self.height = len(self.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 KEY.UNHANDLED
        return menuitem.do_action()

    def do_action(self):
        menuitem = self.menuitems[self.pos]
        return menuitem.do_action()

    def draw(self, wr):
        height = self.height + 1
        row = wr.nextrow
        toprow = row - wr.offset
        fixrow = max(0, min(toprow,  wr.height - height))
        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, width)
            else:
                wr.menu(menuitem.name, width)
        wr.rectangle(row, col, height - 1, width + 4)


HELP_TEXT = """
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=(), help_file=None):
        self.screen = Screen()
        self.focus = 0
        self.headers = headers
        self.footers = footers
        self.current_widget = self
        self.status(None)  # default text
        self.widgets = widgets
        self.screen_row = 0
        self.offset = None
        self.scr = None  # inititalized in run()
        self.help_mode = False
        self.help_offset = 0
        self.cursor_col = 0  # position of cursor in MultiLineEdit
        self.cursor_pos = (0, 0)
        self.popup_offset = 0
        self.quit_action = None
        self.cut_lines = []
        self.cut_extend = False
        self.help_file = help_file

    def run(self):
        try:
            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),
            ]
            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:
            self.screen.finish()
            for logline in self.log.loglines:
                print(logline)

    def do_quit(self):
        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)

    def get_menu(self):
        return self.context_menu

    def get_key(self, timeout=None):
        while True:
            self.refresh()
            key = self.screen.get_key(timeout)
            if key is None:
                continue
            self.status(None)
            if isinstance(key, str):
                self.cut_extend = False
                return key
            if 0 <= key < 32:  # control keys
                if key != KEY.CUT:
                    self.cut_extend = False
                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
                        return result
            else:
                if key != KEY.GOTO_MAIN:
                    self.cut_extend = False
            return key

    def handle(self, main):
        if self.help_mode:
            widgets = self.widgets
            try:
                help_widget = HelpWidget(self,)
                self.widgets = [help_widget]
                key = super().handle(main)
                return key
            finally:
                self.widgets = widgets
                self.help_mode = False
        else:
            return super().handle(main)

    def handle_help(self):
        self.help_mode = True
        self.popupmenu = None

    def persistent_status(self):
        return 'ctrl-X: context menu/help  ctrl-Q: exit'

    def current_row(self):
        if self.help_mode:
            return self.widgets[0].current_row()
        return super().current_row()

    def refresh(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)

            bot = wr.height - botmargin
            end_scroll = bot - topmargin - 1 - wr.height // 5

            current_row = self.current_row()

            offsetlimit = max(0, self.height() - end_scroll)
            bot_limit = max(self.screen_row, int(end_scroll * 0.7))
            top_limit = min(self.screen_row, int(end_scroll * 0.2) + 1, bot_limit)
            if self.offset is None:
                self.offset = current_row - max(topmargin, self.screen_row)
            offset = clamp(self.offset, current_row - top_limit, current_row - bot_limit)
            self.offset = clamp(offset, 0, offsetlimit)
            self.screen_row = current_row - self.offset

            wr.cursor_visible = False

            wr.move(bot, 0)
            for widget in self.footers:
                widget.draw(wr)
            wr.move(0, 0)

            for widget in self.headers:
                widget.draw(wr)
            wr.offset = self.offset
            wr.top, wr.bot = topmargin, bot
            self.draw_widgets(wr, True)
            wr.top, wr.bot = 0, wr.height
            if self.popupmenu:
                col = self.popup_offset
                col += self.cursor_pos[1]
                wr.move(current_row, col)
                self.popupmenu.draw(wr)
            self.screen.set_cursor(wr.cursor_visible, *self.cursor_pos)

    def status(self, text, warn=None):
        if self.statusbar:
            st = self.persistent_status()
            if text is None:  # reset
                self.statusbar.set(st)
            else:
                self.statusbar.set(f'{text}  {st}', warn)

    def finish(self, exc):
        return True
