<filename>metaindexmanager/panel.py
import threading
import curses

from cursedspace import ScrollPanel, Key

from metaindexmanager.utils import logger


NEXT_ITEM = 'next-item'
PREV_ITEM = 'previous-item'
NEXT_PAGE = 'next-page'
PREV_PAGE = 'previous-page'
TO_START = 'go-to-start'
TO_END = 'go-to-end'
LIST_SCROLL_COMMANDS = [
    NEXT_ITEM,
    PREV_ITEM,
    NEXT_PAGE,
    PREV_PAGE,
    TO_START,
    TO_END,
]

OPTION_PAGING = 'paging'
OPTION_SCROLL_MARGIN = 'scroll-margin'


class BlockingTask(ScrollPanel):
    INDICATOR_1 = '-\\|/'
    # INDICATOR_2 = '.oOo'
    INDICATOR_2 = '▂▃▄▅▆▇▆▅▄▃▂'

    def __init__(self, app):
        super().__init__(app)
        self.border = ScrollPanel.BORDER_ALL
        self.target = None
        self.task = None
        self._title = "Blocking task"
        self._changed = False
        self._progress = None
        self._description = []
        self._status = None
        self.indicator = self.INDICATOR_1
        self.indicator_pos = (0, 0)
        self.indicator_idx = 0
        self.autoresize()

    def handle_key(self, key):
        return False

    def check(self):
        if self.task is None or not self.task.is_alive():
            return False

        if self._changed:
            self._changed = False
            self.paint(True)
        else:
            self.paint_indicator()
        
        threading.Timer(0.5, lambda: self.app.callbacks.put((self, self.check))).start()

        return True

    def paint(self, clear=False):
        super().paint(clear)

        height, width = self.dim

        text = self._title[:width-3]
        self.win.addstr(1, 1, text, curses.A_BOLD)

        if self._progress is not None:
            y = 3
            x = 2
            bar_w = width - 4
            if isinstance(self._progress, tuple):
                percent = float(self._progress[0])/self._progress[1]
            else:
                percent = self._progress/100.0
            percent = min(1.0, max(0.0, percent))
            completed_w = int(bar_w*percent)
            self._indicator_pos = (y, x + completed_w)
            self.win.addstr(y, x, "▒"*bar_w)
            self.win.addstr(y, x, "█"*completed_w)

        self.win.addstr(4, 1, " "*(width-2))
        if self._status is not None:
            self.win.addstr(4, 1, self._status[:width-2])

        self.win.noutrefresh()
        self.paint_indicator()

    def paint_indicator(self):
        self.win.addstr(self._indicator_pos[0], self._indicator_pos[1], self.indicator[self.indicator_idx])
        self.indicator_idx = (self.indicator_idx + 1) % len(self.indicator)
        self.win.noutrefresh()

    def autoresize(self):
        maxheight, maxwidth = self.app.size()
        width = min(maxwidth, max(20, 3*maxwidth//4))
        height = min(maxheight, max(5, maxheight//3))
        y = (maxheight-height)//2
        x = (maxwidth-width)//2
        logger.debug(f"Autoresizing blocking task window to {height}x{width} +{x}:{y}")
        if self._progress is None:
            self._indicator_pos = (0, width-3)
        self.resize(height, width)
        self.move(y, x)

    def status(self, status):
        """Update the status text"""
        self._status = status
        self._changed = True

    def title(self, title):
        """Set the title of the task"""
        self._title = title
        self._changed = True

    def progress(self, current):
        """Update the progress of the task

        current may be an int with the progress in percent (0..100)
        or it may be a tuple with (completed subtasks, number of subtasks)"""
        self._progress = current
        if self._progress is None:
            self.indicator = self.INDICATOR_1
        else:
            self.indicator = self.INDICATOR_2
        self._changed = True

    def destroy(self):
        super().destroy()
        self.app.blocking_task = None
        self.app.paint()
        return True

    def run(self):
        self.task = threading.Thread(target=self.do_run)
        self.task.start()
        # only paint if the task takes longer than 0.1
        self.task.join(0.1)
        logger.debug("Blocking task launched")

    def do_run(self):
        self.app.callbacks.put((self, self.check))
        try:
            self.target()
        except Exception as exc:
            logger.error(f"Blocking task failed: {exc}")
        self.app.callbacks.put((self, self.destroy))


class ListPanel(ScrollPanel):
    def __init__(self, application, *args, **kwargs):
        super().__init__(application, *args, **kwargs)
        self.reload_keybindings()
        self._is_busy = threading.Lock()
        self.multi_selection = []

    def reload_keybindings(self):
        self.SCROLL_NEXT = extract_key_sequence(self.app, NEXT_ITEM)
        self.SCROLL_PREVIOUS = extract_key_sequence(self.app, PREV_ITEM)
        self.SCROLL_NEXT_PAGE = extract_key_sequence(self.app, NEXT_PAGE)
        self.SCROLL_PREVIOUS_PAGE = extract_key_sequence(self.app, PREV_PAGE)
        self.SCROLL_TO_START = extract_key_sequence(self.app, TO_START)
        self.SCROLL_TO_END = extract_key_sequence(self.app, TO_END)

    def title(self):
        raise NotImplementedError()

    @property
    def selected_items(self):
        if len(self.multi_selection) == 0:
            return [self.selected_item]
        return self.multi_selection

    @property
    def is_busy(self):
        return self._is_busy.locked()

    def run_blocking(self, target, *args, **kwargs):
        """Run the function 'target' as a blocking task

        Pass *args, and **kwargs along to target.
        'target' must accept the blockingtask class as its first argument.
        """
        if self.app.blocking_task is not None:
            self.app.error("Another blocking task is already running")

        self.app.blocking_task = BlockingTask(self.app)
        self.app.blocking_task.target = lambda: target(self.app.blocking_task, *args, **kwargs)
        self.app.blocking_task.run()

    def run_in_background(self, target):
        """Run the function 'target' as a thread in the background
        """
        if self.is_busy:
            raise RuntimeError("Already busy")
        thread = threading.Thread(target=lambda: self._do_run_in_background(target))
        logger.debug(f"{self} starts a background task")
        thread.start()

    def _do_run_in_background(self, target):
        with self._is_busy:
            target()

    def handle_key(self, key):
        if self.is_busy:
            return False

        return super().handle_key(key)

    def configuration_changed(self, name=None):
        changed = False

        if name == OPTION_PAGING or name is None:
            new_value = self.app.configuration.bool('all', OPTION_PAGING, str(self.SCROLL_PAGING))
            changed = new_value != self.SCROLL_PAGING
            self.SCROLL_PAGING = new_value

        if name == OPTION_SCROLL_MARGIN or name is None:
            new_value = self.app.configuration.number('all', OPTION_SCROLL_MARGIN, str(self.SCROLL_MARGIN))
            if new_value is not None and new_value >= 0:
                changed = changed or new_value != self.SCROLL_MARGIN
                self.SCROLL_MARGIN = new_value

        if changed and self.win is not None:
            self.scroll()
            self.paint(True)

    def focus(self):
        retval = super().focus()
        self.on_focus()
        return retval

    def on_close(self):
        pass

    def on_focus(self):
        pass


def extract_key_sequence(application, commandname):
    return [keys
            for scope, keys, cmd in application.keys
            if cmd[0] == commandname and scope == 'any']

<filename>metaindexmanager/panel.py
import threading
import curses

from cursedspace import ScrollPanel, Key

from metaindexmanager.utils import logger


NEXT_ITEM = 'next-item'
PREV_ITEM = 'previous-item'
NEXT_PAGE = 'next-page'
PREV_PAGE = 'previous-page'
TO_START = 'go-to-start'
TO_END = 'go-to-end'
LIST_SCROLL_COMMANDS = [
    NEXT_ITEM,
    PREV_ITEM,
    NEXT_PAGE,
    PREV_PAGE,
    TO_START,
    TO_END,
]

OPTION_PAGING = 'paging'
OPTION_SCROLL_MARGIN = 'scroll-margin'


class BlockingTask(ScrollPanel):
    INDICATOR_1 = '-\\|/'
    # INDICATOR_2 = '.oOo'
    INDICATOR_2 = '▂▃▄▅▆▇▆▅▄▃▂'

    def __init__(self, app):
        super().__init__(app)
        self.border = ScrollPanel.BORDER_ALL
        self.target = None
        self.task = None
        self._title = "Blocking task"
        self._changed = False
        self._progress = None
        self._description = []
        self._status = None
        self.indicator = self.INDICATOR_1
        self.indicator_pos = (0, 0)
        self.indicator_idx = 0
        self.autoresize()

    def handle_key(self, key):
        return False

    def check(self):
        if self.task is None or not self.task.is_alive():
            return False

        if self._changed:
            self._changed = False
            self.paint(True)
        else:
            self.paint_indicator()
        
        threading.Timer(0.5, lambda: self.app.callbacks.put((self, self.check))).start()

        return True

    def paint(self, clear=False):
        super().paint(clear)

        height, width = self.dim

        text = self._title[:width-3]
        self.win.addstr(1, 1, text, curses.A_BOLD)

        if self._progress is not None:
            y = 3
            x = 2
            bar_w = width - 4
            if isinstance(self._progress, tuple):
                percent = float(self._progress[0])/self._progress[1]
            else:
                percent = self._progress/100.0
            percent = min(1.0, max(0.0, percent))
            completed_w = int(bar_w*percent)
            self._indicator_pos = (y, x + completed_w)
            self.win.addstr(y, x, "▒"*bar_w)
            self.win.addstr(y, x, "█"*completed_w)

        self.win.addstr(4, 1, " "*(width-2))
        if self._status is not None:
            self.win.addstr(4, 1, self._status[:width-2])

        self.win.noutrefresh()
        self.paint_indicator()

    def paint_indicator(self):
        self.win.addstr(self._indicator_pos[0], self._indicator_pos[1], self.indicator[self.indicator_idx])
        self.indicator_idx = (self.indicator_idx + 1) % len(self.indicator)
        self.win.noutrefresh()

    def autoresize(self):
        maxheight, maxwidth = self.app.size()
        width = min(maxwidth, max(20, 3*maxwidth//4))
        height = min(maxheight, max(5, maxheight//3))
        y = (maxheight-height)//2
        x = (maxwidth-width)//2
        logger.debug(f"Autoresizing blocking task window to {height}x{width} +{x}:{y}")
        if self._progress is None:
            self._indicator_pos = (0, width-3)
        self.resize(height, width)
        self.move(y, x)

    def status(self, status):
        """Update the status text"""
        self._status = status
        self._changed = True

    def title(self, title):
        """Set the title of the task"""
        self._title = title
        self._changed = True

    def progress(self, current):
        """Update the progress of the task

        current may be an int with the progress in percent (0..100)
        or it may be a tuple with (completed subtasks, number of subtasks)"""
        self._progress = current
        if self._progress is None:
            self.indicator = self.INDICATOR_1
        else:
            self.indicator = self.INDICATOR_2
        self._changed = True

    def destroy(self):
        super().destroy()
        self.app.blocking_task = None
        self.app.paint()
        return True

    def run(self):
        self.task = threading.Thread(target=self.do_run)
        self.task.start()
        # only paint if the task takes longer than 0.1
        self.task.join(0.1)
        logger.debug("Blocking task launched")

    def do_run(self):
        self.app.callbacks.put((self, self.check))
        try:
            self.target()
        except Exception as exc:
            logger.error(f"Blocking task failed: {exc}")
        self.app.callbacks.put((self, self.destroy))


class ListPanel(ScrollPanel):
    def __init__(self, application, *args, **kwargs):
        super().__init__(application, *args, **kwargs)
        self.reload_keybindings()
        self._is_busy = threading.Lock()
        self.multi_selection = []

    def reload_keybindings(self):
        self.SCROLL_NEXT = extract_key_sequence(self.app, NEXT_ITEM)
        self.SCROLL_PREVIOUS = extract_key_sequence(self.app, PREV_ITEM)
        self.SCROLL_NEXT_PAGE = extract_key_sequence(self.app, NEXT_PAGE)
        self.SCROLL_PREVIOUS_PAGE = extract_key_sequence(self.app, PREV_PAGE)
        self.SCROLL_TO_START = extract_key_sequence(self.app, TO_START)
        self.SCROLL_TO_END = extract_key_sequence(self.app, TO_END)

    def title(self):
        raise NotImplementedError()

    @property
    def selected_items(self):
        if len(self.multi_selection) == 0:
            return [self.selected_item]
        return self.multi_selection

    @property
    def is_busy(self):
        return self._is_busy.locked()

    def run_blocking(self, target, *args, **kwargs):
        """Run the function 'target' as a blocking task

        Pass *args, and **kwargs along to target.
        'target' must accept the blockingtask class as its first argument.
        """
        if self.app.blocking_task is not None:
            self.app.error("Another blocking task is already running")

        self.app.blocking_task = BlockingTask(self.app)
        self.app.blocking_task.target = lambda: target(self.app.blocking_task, *args, **kwargs)
        self.app.blocking_task.run()

    def run_in_background(self, target):
        """Run the function 'target' as a thread in the background
        """
        if self.is_busy:
            raise RuntimeError("Already busy")
        thread = threading.Thread(target=lambda: self._do_run_in_background(target))
        logger.debug(f"{self} starts a background task")
        thread.start()

    def _do_run_in_background(self, target):
        with self._is_busy:
            target()

    def handle_key(self, key):
        if self.is_busy:
            return False

        return super().handle_key(key)

    def configuration_changed(self, name=None):
        changed = False

        if name == OPTION_PAGING or name is None:
            new_value = self.app.configuration.bool('all', OPTION_PAGING, str(self.SCROLL_PAGING))
            changed = new_value != self.SCROLL_PAGING
            self.SCROLL_PAGING = new_value

        if name == OPTION_SCROLL_MARGIN or name is None:
            new_value = self.app.configuration.number('all', OPTION_SCROLL_MARGIN, str(self.SCROLL_MARGIN))
            if new_value is not None and new_value >= 0:
                changed = changed or new_value != self.SCROLL_MARGIN
                self.SCROLL_MARGIN = new_value

        if changed and self.win is not None:
            self.scroll()
            self.paint(True)

    def focus(self):
        retval = super().focus()
        self.on_focus()
        return retval

    def on_close(self):
        pass

    def on_focus(self):
        pass


def extract_key_sequence(application, commandname):
    return [keys
            for scope, keys, cmd in application.keys
            if cmd[0] == commandname and scope == 'any']

# INDICATOR_2 = '.oOo' Update the status text Set the title of the task Update the progress of the task          current may be an int with the progress in percent (0..100)         or it may be a tuple with (completed subtasks, number of subtasks) # only paint if the task takes longer than 0.1 Run the function 'target' as a blocking task          Pass *args, and **kwargs along to target.         'target' must accept the blockingtask class as its first argument. Run the function 'target' as a thread in the background