"""
BECConsole is a Qt widget that runs a Bash shell.

BECConsole VT100 emulation is powered by Pyte,
(https://github.com/selectel/pyte).
"""

import collections
import fcntl
import html
import os
import pty
import re
import signal
import sys
import time

import pyte
from pygments.token import Token
from pyte.screens import History
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Property as pyqtProperty
from qtpy.QtCore import QSize, QSocketNotifier, Qt, QTimer
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy

from bec_widgets.utils.error_popups import SafeSlot as Slot

ansi_colors = {
    "black": "#000000",
    "red": "#CD0000",
    "green": "#00CD00",
    "brown": "#996633",  # Brown, replacing the yellow
    "blue": "#0000EE",
    "magenta": "#CD00CD",
    "cyan": "#00CDCD",
    "white": "#E5E5E5",
    "brightblack": "#7F7F7F",
    "brightred": "#FF0000",
    "brightgreen": "#00FF00",
    "brightyellow": "#FFFF00",
    "brightblue": "#5C5CFF",
    "brightmagenta": "#FF00FF",
    "brightcyan": "#00FFFF",
    "brightwhite": "#FFFFFF",
}

control_keys_mapping = {
    QtCore.Qt.Key_A: b"\x01",  # Ctrl-A
    QtCore.Qt.Key_B: b"\x02",  # Ctrl-B
    QtCore.Qt.Key_C: b"\x03",  # Ctrl-C
    QtCore.Qt.Key_D: b"\x04",  # Ctrl-D
    QtCore.Qt.Key_E: b"\x05",  # Ctrl-E
    QtCore.Qt.Key_F: b"\x06",  # Ctrl-F
    QtCore.Qt.Key_G: b"\x07",  # Ctrl-G (Bell)
    QtCore.Qt.Key_H: b"\x08",  # Ctrl-H (Backspace)
    QtCore.Qt.Key_I: b"\x09",  # Ctrl-I (Tab)
    QtCore.Qt.Key_J: b"\x0a",  # Ctrl-J (Line Feed)
    QtCore.Qt.Key_K: b"\x0b",  # Ctrl-K (Vertical Tab)
    QtCore.Qt.Key_L: b"\x0c",  # Ctrl-L (Form Feed)
    QtCore.Qt.Key_M: b"\x0d",  # Ctrl-M (Carriage Return)
    QtCore.Qt.Key_N: b"\x0e",  # Ctrl-N
    QtCore.Qt.Key_O: b"\x0f",  # Ctrl-O
    QtCore.Qt.Key_P: b"\x10",  # Ctrl-P
    QtCore.Qt.Key_Q: b"\x11",  # Ctrl-Q
    QtCore.Qt.Key_R: b"\x12",  # Ctrl-R
    QtCore.Qt.Key_S: b"\x13",  # Ctrl-S
    QtCore.Qt.Key_T: b"\x14",  # Ctrl-T
    QtCore.Qt.Key_U: b"\x15",  # Ctrl-U
    QtCore.Qt.Key_V: b"\x16",  # Ctrl-V
    QtCore.Qt.Key_W: b"\x17",  # Ctrl-W
    QtCore.Qt.Key_X: b"\x18",  # Ctrl-X
    QtCore.Qt.Key_Y: b"\x19",  # Ctrl-Y
    QtCore.Qt.Key_Z: b"\x1a",  # Ctrl-Z
    QtCore.Qt.Key_Escape: b"\x1b",  # Ctrl-Escape
    QtCore.Qt.Key_Backslash: b"\x1c",  # Ctrl-\
    QtCore.Qt.Key_Underscore: b"\x1f",  # Ctrl-_
}

normal_keys_mapping = {
    QtCore.Qt.Key_Return: b"\n",
    QtCore.Qt.Key_Space: b" ",
    QtCore.Qt.Key_Enter: b"\n",
    QtCore.Qt.Key_Tab: b"\t",
    QtCore.Qt.Key_Backspace: b"\x08",
    QtCore.Qt.Key_Home: b"\x47",
    QtCore.Qt.Key_End: b"\x4f",
    QtCore.Qt.Key_Left: b"\x02",
    QtCore.Qt.Key_Up: b"\x10",
    QtCore.Qt.Key_Right: b"\x06",
    QtCore.Qt.Key_Down: b"\x0e",
    QtCore.Qt.Key_PageUp: b"\x49",
    QtCore.Qt.Key_PageDown: b"\x51",
    QtCore.Qt.Key_F1: b"\x1b\x31",
    QtCore.Qt.Key_F2: b"\x1b\x32",
    QtCore.Qt.Key_F3: b"\x1b\x33",
    QtCore.Qt.Key_F4: b"\x1b\x34",
    QtCore.Qt.Key_F5: b"\x1b\x35",
    QtCore.Qt.Key_F6: b"\x1b\x36",
    QtCore.Qt.Key_F7: b"\x1b\x37",
    QtCore.Qt.Key_F8: b"\x1b\x38",
    QtCore.Qt.Key_F9: b"\x1b\x39",
    QtCore.Qt.Key_F10: b"\x1b\x30",
    QtCore.Qt.Key_F11: b"\x45",
    QtCore.Qt.Key_F12: b"\x46",
}


def QtKeyToAscii(event):
    """
    Convert the Qt key event to the corresponding ASCII sequence for
    the terminal. This works fine for standard alphanumerical characters, but
    most other characters require terminal specific control sequences.

    The conversion below works for TERM="linux" terminals.
    """
    if sys.platform == "darwin":
        # special case for MacOS
        # /!\ Qt maps ControlModifier to CMD
        # CMD-C, CMD-V for copy/paste
        # CTRL-C and other modifiers -> key mapping
        if event.modifiers() == QtCore.Qt.MetaModifier:
            if event.key() == Qt.Key_Backspace:
                return control_keys_mapping.get(Qt.Key_W)
            return control_keys_mapping.get(event.key())
        elif event.modifiers() == QtCore.Qt.ControlModifier:
            if event.key() == Qt.Key_C:
                # copy
                return "copy"
            elif event.key() == Qt.Key_V:
                # paste
                return "paste"
            return None
        else:
            return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))
    if event.modifiers() == QtCore.Qt.ControlModifier:
        return control_keys_mapping.get(event.key())
    else:
        return normal_keys_mapping.get(event.key(), event.text().encode("utf8"))


class Screen(pyte.HistoryScreen):
    def __init__(self, stdin_fd, cols, rows, historyLength):
        super().__init__(cols, rows, historyLength, ratio=1 / rows)
        self._fd = stdin_fd

    def write_process_input(self, data):
        """Response to CPR request (for example),
        this can be for other requests
        """
        try:
            os.write(self._fd, data.encode("utf-8"))
        except (IOError, OSError):
            pass

    def resize(self, lines, columns):
        lines = lines or self.lines
        columns = columns or self.columns

        if lines == self.lines and columns == self.columns:
            return  # No changes.

        self.dirty.clear()
        self.dirty.update(range(lines))

        self.save_cursor()
        if lines < self.lines:
            if lines <= self.cursor.y:
                nlines_to_move_up = self.lines - lines
                for i in range(nlines_to_move_up):
                    line = self.buffer[i]  # .pop(0)
                    self.history.top.append(line)
                self.cursor_position(0, 0)
                self.delete_lines(nlines_to_move_up)
                self.restore_cursor()
                self.cursor.y -= nlines_to_move_up
        else:
            self.restore_cursor()

        self.lines, self.columns = lines, columns
        self.history = History(
            self.history.top,
            self.history.bottom,
            1 / self.lines,
            self.history.size,
            self.history.position,
        )
        self.set_margins()


class Backend(QtCore.QObject):
    """
    Poll Bash.

    This class will run as a qsocketnotifier (started in ``_TerminalWidget``) and poll the
    file descriptor of the Bash terminal.
    """

    # Signals to communicate with ``_TerminalWidget``.
    dataReady = pyqtSignal(object)
    processExited = pyqtSignal()

    def __init__(self, fd, cols, rows):
        super().__init__()

        # File descriptor that connects to Bash process.
        self.fd = fd

        # Setup Pyte (hard coded display size for now).
        self.screen = Screen(self.fd, cols, rows, 10000)
        self.stream = pyte.ByteStream()
        self.stream.attach(self.screen)

        self.notifier = QSocketNotifier(fd, QSocketNotifier.Read)
        self.notifier.activated.connect(self._fd_readable)

    def _fd_readable(self):
        """
        Poll the Bash output, run it through Pyte, and notify
        """
        # Read the shell output until the file descriptor is closed.
        try:
            out = os.read(self.fd, 2**16)
        except OSError:
            self.processExited.emit()
            self.notifier.setEnabled(False)
            return

        # Feed output into Pyte's state machine and send the new screen
        # output to the GUI
        self.stream.feed(out)
        self.dataReady.emit(self.screen)


class BECConsole(QtWidgets.QWidget):
    """Container widget for the terminal text area"""

    PLUGIN = True
    ICON_NAME = "terminal"

    prompt = pyqtSignal(bool)

    def __init__(self, parent=None, cols=132):
        super().__init__(parent)

        self.term = _TerminalWidget(self, cols, rows=43)
        self.term.prompt.connect(self.prompt)  # forward signal from term to this widget

        self.scroll_bar = QScrollBar(Qt.Vertical, self)
        # self.scroll_bar.hide()
        layout = QHBoxLayout(self)
        layout.addWidget(self.term)
        layout.addWidget(self.scroll_bar)
        layout.setAlignment(Qt.AlignLeft | Qt.AlignTop)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding)

        pal = QPalette()
        self.set_bgcolor(pal.window().color())
        self.set_fgcolor(pal.windowText().color())
        self.term.set_scroll_bar(self.scroll_bar)
        self.set_cmd("bec --nogui")

        self._check_designer_timer = QTimer()
        self._check_designer_timer.timeout.connect(self.check_designer)
        self._check_designer_timer.start(1000)

    def minimumSizeHint(self):
        size = self.term.sizeHint()
        size.setWidth(size.width() + self.scroll_bar.width())
        return size

    def sizeHint(self):
        return self.minimumSizeHint()

    def check_designer(self, calls={"n": 0}):
        calls["n"] += 1
        if self.term.fd is not None:
            # already started
            self._check_designer_timer.stop()
        elif self.window().windowTitle().endswith("[Preview]"):
            # assuming Designer preview -> start
            self._check_designer_timer.stop()
            self.term.start()
        elif calls["n"] >= 3:
            # assuming not in Designer -> stop checking
            self._check_designer_timer.stop()

    def get_rows(self):
        return self.term.rows

    def set_rows(self, rows):
        self.term.rows = rows
        self.adjustSize()
        self.updateGeometry()

    def get_cols(self):
        return self.term.cols

    def set_cols(self, cols):
        self.term.cols = cols
        self.adjustSize()
        self.updateGeometry()

    def get_bgcolor(self):
        return QColor.fromString(self.term.bg_color)

    def set_bgcolor(self, color):
        self.term.bg_color = color.name(QColor.HexRgb)

    def get_fgcolor(self):
        return QColor.fromString(self.term.fg_color)

    def set_fgcolor(self, color):
        self.term.fg_color = color.name(QColor.HexRgb)

    def get_cmd(self):
        return self.term._cmd

    def set_cmd(self, cmd):
        self.term._cmd = cmd
        if self.term.fd is None:
            # not started yet
            self.term.clear()
            self.term.appendHtml(f"<h2>BEC Console - {repr(cmd)}</h2>")

    def start(self, deactivate_ctrl_d=True):
        self.term.start(deactivate_ctrl_d=deactivate_ctrl_d)

    def push(self, text, hit_return=False):
        """Push some text to the terminal"""
        return self.term.push(text, hit_return=hit_return)

    def execute_command(self, command):
        self.push(command, hit_return=True)

    def set_prompt_tokens(self, *tokens):
        """Prepare regexp to identify prompt, based on tokens

        Tokens are returned from get_ipython().prompts.in_prompt_tokens()
        """
        regex_parts = []
        for token_type, token_value in tokens:
            if token_type == Token.PromptNum:  # Handle dynamic prompt number
                regex_parts.append(r"[\d\?]+")  # Match one or more digits or '?'
            else:
                # Escape other prompt parts (e.g., "In [", "]: ")
                if not token_value:
                    regex_parts.append(".+?")  # arbitrary string
                else:
                    regex_parts.append(re.escape(token_value))

        # Combine into a single regex
        prompt_pattern = "".join(regex_parts)
        self.term._prompt_re = re.compile(prompt_pattern + r"\s*$")

    def terminate(self, timeout=10):
        self.term.stop(timeout=timeout)

    def send_ctrl_c(self, timeout=None):
        self.term.send_ctrl_c(timeout)

    cols = pyqtProperty(int, get_cols, set_cols)
    rows = pyqtProperty(int, get_rows, set_rows)
    bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor)
    fgcolor = pyqtProperty(QColor, get_fgcolor, set_fgcolor)
    cmd = pyqtProperty(str, get_cmd, set_cmd)


class _TerminalWidget(QtWidgets.QPlainTextEdit):
    """
    Start ``Backend`` process and render Pyte output as text.
    """

    prompt = pyqtSignal(bool)

    def __init__(self, parent, cols=125, rows=50, **kwargs):
        # regexp to match prompt
        self._prompt_re = None
        # last prompt
        self._prompt_str = None
        # process pid
        self.pid = None
        # file descriptor to communicate with the subprocess
        self.fd = None
        self.backend = None
        # command to execute
        self._cmd = ""
        # should ctrl-d be deactivated ? (prevent Python exit)
        self._deactivate_ctrl_d = False

        # Default colors
        pal = QPalette()
        self._fg_color = pal.text().color().name()
        self._bg_color = pal.base().color().name()

        # Specify the terminal size in terms of lines and columns.
        self._rows = rows
        self._cols = cols
        self.output = collections.deque()

        super().__init__(parent)

        self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Expanding)

        # Disable default scrollbars (we use our own, to be set via .set_scroll_bar())
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.scroll_bar = None

        # Use Monospace fonts and disable line wrapping.
        self.setFont(QtGui.QFont("Courier", 9))
        self.setFont(QtGui.QFont("Monospace"))
        self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
        fmt = QtGui.QFontMetrics(self.font())
        char_width = fmt.width("w")
        self.setCursorWidth(char_width)

        self.adjustSize()
        self.updateGeometry()
        self.update_stylesheet()

    @property
    def bg_color(self):
        return self._bg_color

    @bg_color.setter
    def bg_color(self, hexcolor):
        self._bg_color = hexcolor
        self.update_stylesheet()

    @property
    def fg_color(self):
        return self._fg_color

    @fg_color.setter
    def fg_color(self, hexcolor):
        self._fg_color = hexcolor
        self.update_stylesheet()

    def update_stylesheet(self):
        self.setStyleSheet(
            f"QPlainTextEdit {{ border: 0; color: {self._fg_color}; background-color: {self._bg_color}; }} "
        )

    @property
    def rows(self):
        return self._rows

    @rows.setter
    def rows(self, rows: int):
        if self.backend is None:
            # not initialized yet, ok to change
            self._rows = rows
            self.adjustSize()
            self.updateGeometry()
        else:
            raise RuntimeError("Cannot change rows after console is started.")

    @property
    def cols(self):
        return self._cols

    @cols.setter
    def cols(self, cols: int):
        if self.fd is None:
            # not initialized yet, ok to change
            self._cols = cols
            self.adjustSize()
            self.updateGeometry()
        else:
            raise RuntimeError("Cannot change cols after console is started.")

    def start(self, deactivate_ctrl_d: bool = False):
        self._deactivate_ctrl_d = deactivate_ctrl_d

        self.update_term_size()

        # Start the Bash process
        self.pid, self.fd = self.fork_shell()

        if self.fd:
            # Create the ``Backend`` object
            self.backend = Backend(self.fd, self.cols, self.rows)
            self.backend.dataReady.connect(self.data_ready)
            self.backend.processExited.connect(self.process_exited)
        else:
            self.process_exited()

    def process_exited(self):
        self.fd = None
        self.clear()
        self.appendHtml(f"<br><h2>{repr(self._cmd)} - Process exited.</h2>")
        self.setReadOnly(True)

    def send_ctrl_c(self, wait_prompt=True, timeout=None):
        """Send CTRL-C to the process

        If wait_prompt=True (default), wait for a new prompt after CTRL-C
        If no prompt is displayed after 'timeout' seconds, TimeoutError is raised
        """
        os.kill(self.pid, signal.SIGINT)
        if wait_prompt:
            timeout_error = False
            if timeout:

                def set_timeout_error():
                    nonlocal timeout_error
                    timeout_error = True

                timeout_timer = QTimer()
                timeout_timer.singleShot(timeout * 1000, set_timeout_error)
            while self._prompt_str is None:
                QApplication.instance().process_events()
                if timeout_error:
                    raise TimeoutError(
                        f"CTRL-C: could not get back to prompt after {timeout} seconds."
                    )

    def _is_running(self):
        if os.waitpid(self.pid, os.WNOHANG) == (0, 0):
            return True
        return False

    def stop(self, kill=True, timeout=None):
        """Stop the running process

        SIGTERM is the default signal for terminating processes.

        If kill=True (default), SIGKILL will be sent if the process does not exit after timeout
        """
        # try to exit gracefully
        os.kill(self.pid, signal.SIGTERM)

        # wait until process is truly dead
        t0 = time.perf_counter()
        while self._is_running():
            time.sleep(1)
            if timeout is not None and time.perf_counter() - t0 > timeout:
                # still alive after 'timeout' seconds
                if kill:
                    # send SIGKILL and make a last check in loop
                    os.kill(self.pid, signal.SIGKILL)
                    kill = False
                else:
                    # still running after timeout...
                    raise TimeoutError(
                        f"Could not terminate process with pid: {self.pid} within timeout"
                    )
        self.process_exited()

    def data_ready(self, screen):
        """Handle new screen: redraw, set scroll bar max and slider, move cursor to its position

        This method is triggered via a signal from ``Backend``.
        """
        self.redraw_screen()
        self.adjust_scroll_bar()
        self.move_cursor()

    def minimumSizeHint(self):
        """Return minimum size for current cols and rows"""
        fmt = QtGui.QFontMetrics(self.font())
        char_width = fmt.width("w")
        char_height = fmt.height()
        width = char_width * self.cols
        height = char_height * self.rows
        return QSize(width, height)

    def sizeHint(self):
        return self.minimumSizeHint()

    def set_scroll_bar(self, scroll_bar):
        self.scroll_bar = scroll_bar
        self.scroll_bar.setMinimum(0)
        self.scroll_bar.valueChanged.connect(self.scroll_value_change)

    def scroll_value_change(self, value, old={"value": -1}):
        if self.backend is None:
            return
        if old["value"] == -1:
            old["value"] = self.scroll_bar.maximum()
        if value <= old["value"]:
            # scroll up
            # value is number of lines from the start
            nlines = old["value"] - value
            # history ratio gives prev_page == 1 line
            for i in range(nlines):
                self.backend.screen.prev_page()
        else:
            # scroll down
            nlines = value - old["value"]
            for i in range(nlines):
                self.backend.screen.next_page()
        old["value"] = value
        self.redraw_screen()

    def adjust_scroll_bar(self):
        sb = self.scroll_bar
        sb.valueChanged.disconnect(self.scroll_value_change)
        tmp = len(self.backend.screen.history.top) + len(self.backend.screen.history.bottom)
        sb.setMaximum(tmp if tmp > 0 else 0)
        sb.setSliderPosition(tmp if tmp > 0 else 0)
        # if tmp > 0:
        #    # show scrollbar, but delayed - prevent recursion with widget size change
        #    QTimer.singleShot(0, scrollbar.show)
        # else:
        #    QTimer.singleShot(0, scrollbar.hide)
        sb.valueChanged.connect(self.scroll_value_change)

    def write(self, data):
        try:
            os.write(self.fd, data)
        except (IOError, OSError):
            self.process_exited()

    @Slot(object)
    def keyPressEvent(self, event):
        """
        Redirect all keystrokes to the terminal process.
        """
        if self.fd is None:
            # not started
            return
        # Convert the Qt key to the correct ASCII code.
        if (
            self._deactivate_ctrl_d
            and event.modifiers() == QtCore.Qt.ControlModifier
            and event.key() == QtCore.Qt.Key_D
        ):
            return None

        code = QtKeyToAscii(event)
        if code == "copy":
            # MacOS only: CMD-C handling
            self.copy()
        elif code == "paste":
            # MacOS only: CMD-V handling
            self._push_clipboard()
        elif code is not None:
            self.write(code)

    def push(self, text, hit_return=False):
        """
        Write 'text' to terminal
        """
        self.write(text.encode("utf-8"))
        if hit_return:
            self.write(b"\n")

    def contextMenuEvent(self, event):
        if self.fd is None:
            return
        menu = self.createStandardContextMenu()
        for action in menu.actions():
            # remove all actions except copy and paste
            if "opy" in action.text():
                # redefine text without shortcut
                # since it probably clashes with control codes (like CTRL-C etc)
                action.setText("Copy")
                continue
            if "aste" in action.text():
                # redefine text without shortcut
                action.setText("Paste")
                # paste -> have to insert with self.push
                action.triggered.connect(self._push_clipboard)
                continue
            menu.removeAction(action)
        menu.exec_(event.globalPos())

    def _push_clipboard(self):
        clipboard = QApplication.instance().clipboard()
        self.push(clipboard.text())

    def move_cursor(self):
        textCursor = self.textCursor()
        textCursor.setPosition(0)
        textCursor.movePosition(
            QTextCursor.Down, QTextCursor.MoveAnchor, self.backend.screen.cursor.y
        )
        textCursor.movePosition(
            QTextCursor.Right, QTextCursor.MoveAnchor, self.backend.screen.cursor.x
        )
        self.setTextCursor(textCursor)

    def mouseReleaseEvent(self, event):
        if self.fd is None:
            return
        if event.button() == Qt.MiddleButton:
            # push primary selection buffer ("mouse clipboard") to terminal
            clipboard = QApplication.instance().clipboard()
            if clipboard.supportsSelection():
                self.push(clipboard.text(QClipboard.Selection))
            return None
        elif event.button() == Qt.LeftButton:
            # left button click
            textCursor = self.textCursor()
            if textCursor.selectedText():
                # mouse was used to select text -> nothing to do
                pass
            else:
                # a simple 'click', move scrollbar to end
                self.scroll_bar.setSliderPosition(self.scroll_bar.maximum())
                self.move_cursor()
                return None
        return super().mouseReleaseEvent(event)

    def redraw_screen(self):
        """
        Render the screen as formatted text into the widget.
        """
        screen = self.backend.screen

        # Clear the widget
        if screen.dirty:
            self.clear()
            while len(self.output) < (max(screen.dirty) + 1):
                self.output.append("")
            while len(self.output) > (max(screen.dirty) + 1):
                self.output.pop()

            # Prepare the HTML output
            for line_no in screen.dirty:
                line = text = ""
                style = old_style = ""
                old_idx = 0
                for idx, ch in screen.buffer[line_no].items():
                    text += " " * (idx - old_idx - 1)
                    old_idx = idx
                    style = f"{'background-color:%s;' % ansi_colors.get(ch.bg, ansi_colors['black']) if ch.bg!='default' else ''}{'color:%s;' % ansi_colors.get(ch.fg, ansi_colors['white']) if ch.fg!='default' else ''}{'font-weight:bold;' if ch.bold else ''}{'font-style:italic;' if ch.italics else ''}"
                    if style != old_style:
                        if old_style:
                            line += f"<span style={repr(old_style)}>{html.escape(text, quote=True)}</span>"
                        else:
                            line += html.escape(text, quote=True)
                        text = ""
                        old_style = style
                    text += ch.data
                if style:
                    line += f"<span style={repr(style)}>{html.escape(text, quote=True)}</span>"
                else:
                    line += html.escape(text, quote=True)
                # do a check at the cursor position:
                # it is possible x pos > output line length,
                # for example if last escape codes are "cursor forward" past end of text,
                # like IPython does for "..." prompt (in a block, like "for" loop or "while" for example)
                # In this case, cursor is at 12 but last text output is at 8 -> insert spaces
                if line_no == screen.cursor.y:
                    llen = len(screen.buffer[line_no])
                    if llen < screen.cursor.x:
                        line += " " * (screen.cursor.x - llen)
                self.output[line_no] = line
            # fill the text area with HTML contents in one go
            self.appendHtml(f"<pre>{chr(10).join(self.output)}</pre>")

            if self._prompt_re is not None:
                text_buf = self.toPlainText()
                prompt = self._prompt_re.search(text_buf)
                if prompt is None:
                    if self._prompt_str:
                        self.prompt.emit(False)
                    self._prompt_str = None
                else:
                    prompt_str = prompt.string.rstrip()
                    if prompt_str != self._prompt_str:
                        self._prompt_str = prompt_str
                        self.prompt.emit(True)

            # did updates, all clean
            screen.dirty.clear()

    def update_term_size(self):
        fmt = QtGui.QFontMetrics(self.font())
        char_width = fmt.width("w")
        char_height = fmt.height()
        self._cols = int(self.width() / char_width)
        self._rows = int(self.height() / char_height)

    def resizeEvent(self, event):
        self.update_term_size()
        if self.fd:
            self.backend.screen.resize(self._rows, self._cols)
            self.redraw_screen()
            self.adjust_scroll_bar()
            self.move_cursor()

    def wheelEvent(self, event):
        if not self.fd:
            return
        y = event.angleDelta().y()
        if y > 0:
            self.backend.screen.prev_page()
        else:
            self.backend.screen.next_page()
        self.redraw_screen()

    def fork_shell(self):
        """
        Fork the current process and execute bec in shell.
        """
        try:
            pid, fd = pty.fork()
        except (IOError, OSError):
            return False
        if pid == 0:
            try:
                ls = os.environ["LANG"].split(".")
            except KeyError:
                ls = []
            if len(ls) < 2:
                ls = ["en_US", "UTF-8"]
            os.putenv("COLUMNS", str(self.cols))
            os.putenv("LINES", str(self.rows))
            os.putenv("TERM", "linux")
            os.putenv("LANG", ls[0] + ".UTF-8")
            if not self._cmd:
                self._cmd = os.environ["SHELL"]
            cmd = self._cmd
            if isinstance(cmd, str):
                cmd = cmd.split()
            try:
                os.execvp(cmd[0], cmd)
            except (IOError, OSError):
                pass
            os._exit(0)
        else:
            # We are in the parent process.
            # Set file control
            fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
            return pid, fd


if __name__ == "__main__":
    import os
    import sys

    from qtpy import QtGui, QtWidgets

    # Create the Qt application and console.
    app = QtWidgets.QApplication([])
    mainwin = QtWidgets.QMainWindow()
    title = "BECConsole"
    mainwin.setWindowTitle(title)

    console = BECConsole(mainwin)
    mainwin.setCentralWidget(console)

    def check_prompt(at_prompt):
        if at_prompt:
            print("NEW PROMPT")
        else:
            print("EXECUTING SOMETHING...")

    console.set_prompt_tokens(
        (Token.OutPromptNum, "•"),
        (Token.Prompt, ""),  # will match arbitrary string,
        (Token.Prompt, " ["),
        (Token.PromptNum, "3"),
        (Token.Prompt, "/"),
        (Token.PromptNum, "1"),
        (Token.Prompt, "] "),
        (Token.Prompt, "❯❯"),
    )
    console.prompt.connect(check_prompt)
    console.start()

    # Show widget and launch Qt's event loop.
    mainwin.show()
    sys.exit(app.exec_())
