# coding=utf-8
"""Extensions for tk.Text"""
import sys
import time
import tkinter
import tkinter as tk
from logging import getLogger
from tkinter import TclError
from tkinter import font as tkfont
from tkinter import ttk
from typing import Optional

logger = getLogger(__name__)


class TweakableText(tk.Text):
    """Allows intercepting Text commands at Tcl-level"""

    def __init__(self, master=None, cnf={}, read_only=False, **kw):
        super().__init__(master=master, cnf=cnf, **kw)

        self._read_only = read_only
        self._suppress_events = False
        self._edit_count: int = 0
        self._last_operation_time: float = time.time()

        self._original_widget_name = self._w + "_orig"
        self.tk.call("rename", self._w, self._original_widget_name)
        self.tk.createcommand(self._w, self._dispatch_tk_operation)
        self._tk_proxies = {}

        self._original_insert = self._register_tk_proxy_function("insert", self.intercept_insert)
        self._original_delete = self._register_tk_proxy_function("delete", self.intercept_delete)
        self._original_mark = self._register_tk_proxy_function("mark", self.intercept_mark)

    def _register_tk_proxy_function(self, operation, function):
        self._tk_proxies[operation] = function
        setattr(self, operation, function)

        def original_function(*args):
            self.tk.call((self._original_widget_name, operation) + args)

        return original_function

    def _dispatch_tk_operation(self, operation, *args):
        f = self._tk_proxies.get(operation)
        try:
            if f:
                return f(*args)
            else:
                return self.tk.call((self._original_widget_name, operation) + args)

        except TclError as e:
            # Some Tk internal actions (eg. paste and cut) can cause this error
            if (
                str(e).lower() == '''text doesn't contain any characters tagged with "sel"'''
                and operation in ["delete", "index", "get"]
                and args in [("sel.first", "sel.last"), ("sel.first",)]
            ):
                pass
            # Don't worry about hitting ends of undo/redo stacks
            elif (
                operation == "edit"
                and args in [("undo",), ("redo",)]
                and str(e).lower() == "nothing to " + args[0]
            ):
                pass
            else:
                logger.exception(
                    "[_dispatch_tk_operation] operation: " + operation + ", args:" + repr(args)
                )
                # traceback.print_exc()

            return ""  # Taken from idlelib.WidgetRedirector

        except Exception as e:
            # Need to catch to avoid the crash?
            logger.exception("Exception in _dispatch_tk_operation")
            from tkinter import messagebox

            messagebox.showerror(
                "Internal error",
                "Error in _dispatch_tk_operation\n" + str(e),
                parent=tkinter._default_root,
            )

    def set_read_only(self, value):
        self._read_only = value

    def is_read_only(self):
        return self._read_only

    def get_edit_count(self) -> int:
        return self._edit_count

    def get_last_operation_time(self) -> float:
        """Returns the timestamp of the widget creation or time or the last time
        the text or cursor location was changed"""
        return self._last_operation_time

    def set_content(self, chars):
        self.direct_delete("1.0", tk.END)
        self.direct_insert("1.0", chars)

    def set_insertwidth(self, new_width):
        """Change cursor width

        NB! Need to be careful with setting text["insertwidth"]!
        My first straightforward solution caused unexplainable
        infinite loop of insertions and deletions in the text
        (Repro: insert a line and a word, select that word and then do Ctrl-Z).

        This solution seems safe but be careful!
        """
        if self._suppress_events:
            return

        if self["insertwidth"] != new_width:
            old_suppress = self._suppress_events
            try:
                self._suppress_events = True
                self.config(insertwidth=new_width)
            finally:
                self._suppress_events = old_suppress

    def intercept_mark(self, *args):
        self.direct_mark(*args)

    def intercept_insert(self, index, chars, tags=None, **kw):
        assert isinstance(chars, str)
        if chars >= "\uf704" and chars <= "\uf70d":  # Function keys F1..F10 in Mac cause these
            pass
        elif self.is_read_only():
            self.bell()
        else:
            self.direct_insert(index, chars, tags, **kw)

    def intercept_delete(self, index1, index2=None, **kw):
        if index1 == "sel.first" and index2 == "sel.last" and not self.has_selection():
            return

        if self.is_read_only():
            self.bell()
        elif self._is_erroneous_delete(index1, index2):
            pass
        else:
            self.direct_delete(index1, index2, **kw)

    def _is_erroneous_delete(self, index1, index2):
        """Paste can cause deletes where index1 is sel.start but text has no selection. This would cause errors"""
        return index1.startswith("sel.") and not self.has_selection()

    def direct_mark(self, *args):
        self._original_mark(*args)
        self._last_operation_time = time.time()
        if args[:2] == ("set", "insert") and not self._suppress_events:
            self.event_generate("<<CursorMove>>")

    def index_sel_first(self):
        # Tk will give error without this check
        if self.tag_ranges("sel"):
            return self.index("sel.first")
        else:
            return None

    def index_sel_last(self):
        if self.tag_ranges("sel"):
            return self.index("sel.last")
        else:
            return None

    def has_selection(self):
        return len(self.tag_ranges("sel")) > 0

    def get_selection_indices(self):
        # If a selection is defined in the text widget, return (start,
        # end) as Tkinter text indices, otherwise return (None, None)
        if self.has_selection():
            return self.index("sel.first"), self.index("sel.last")
        else:
            return None, None

    def direct_insert(self, index, chars, tags=None, **kw):
        self._original_insert(index, chars, tags, **kw)
        self._edit_count += 1
        self._last_operation_time = time.time()
        if not self._suppress_events:
            self.event_generate("<<TextChange>>")

    def direct_delete(self, index1, index2=None, **kw):
        self._original_delete(index1, index2, **kw)
        self._edit_count += 1
        self._last_operation_time = time.time()
        if not self._suppress_events:
            self.event_generate("<<TextChange>>")


class EnhancedText(TweakableText):
    """Text widget with extra navigation and editing aids.
    Provides more comfortable deletion, indentation and deindentation,
    and undo handling. Not specific to Python code.

    Most of the code is adapted from idlelib.EditorWindow.
    """

    def __init__(self, master=None, style="Text", tag_current_line=False, cnf={}, **kw):
        # Parent class shouldn't autoseparate
        # TODO: take client provided autoseparators value into account
        kw["autoseparators"] = False
        self._style = style
        self._original_options = kw.copy()

        super().__init__(master=master, cnf=cnf, **kw)
        self.tabwidth = 4
        self.indent_width = 4

        self._last_event_kind = None
        self._last_key_time = None

        self._bind_keypad()
        self._bind_editing_aids()
        self._bind_movement_aids()
        self._bind_selection_aids()
        self._bind_undo_aids()
        self._bind_mouse_aids()

        self._ui_theme_change_binding = self.bind(
            "<<ThemeChanged>>", self._reload_theme_options, True
        )

        self._initial_configuration = self.configure()
        self._regular_insertwidth = self["insertwidth"]
        self._reload_theme_options()

        self._should_tag_current_line = tag_current_line
        if tag_current_line:
            self.bind("<<CursorMove>>", self._tag_current_line, True)
            self.bind("<<TextChange>>", self._tag_current_line, True)
            self._tag_current_line()

    def should_indent_with_tabs(self):
        return True

    def _bind_mouse_aids(self):
        if _running_on_mac():
            self.bind("<Button-2>", self.on_secondary_click)
            self.bind("<Control-Button-1>", self.on_secondary_click)
        else:
            self.bind("<Button-3>", self.on_secondary_click)

    def _bind_editing_aids(self):
        def if_not_readonly(fun):
            def dispatch(event):
                if not self.is_read_only():
                    return fun(event)
                else:
                    return "break"

            return dispatch

        self.bind("<Control-BackSpace>", if_not_readonly(self.delete_word_left), True)
        self.bind("<Control-Delete>", if_not_readonly(self.delete_word_right), True)
        self.bind("<Control-d>", self._redirect_ctrld, True)
        self.bind("<Control-t>", self._redirect_ctrlt, True)
        self.bind("<Control-f>", self._redirect_ctrlf, True)
        self.bind("<Shift-Control-space>", self._redirect_shift_control_space, True)
        self.bind("<BackSpace>", if_not_readonly(self.perform_smart_backspace), True)
        self.bind("<Return>", if_not_readonly(self.perform_return), True)
        self.bind("<KP_Enter>", if_not_readonly(self.perform_return), True)
        self.bind("<Tab>", if_not_readonly(self.perform_tab), True)
        try:
            # Is needed on eg. Ubuntu with Estonian keyboard
            self.bind("<ISO_Left_Tab>", if_not_readonly(self.perform_tab), True)
        except Exception:
            pass

        if sys.platform == "win32":
            self.bind("<KeyPress>", self._insert_untypable_characters_on_windows, True)

    def _bind_keypad(self):
        """Remap keypad movement events to non-keypad equivalents"""
        # https://github.com/thonny/thonny/issues/1106
        kmap = {
            "<KP_Left>": "<Left>",
            "<KP_Right>": "<Right>",
            "<KP_Up>": "<Up>",
            "<KP_Down>": "<Down>",
            "<KP_Home>": "<Home>",
            "<KP_End>": "<End>",
            "<KP_Next>": "<Next>",
            "<KP_Prior>": "<Prior>",
            "<KP_Enter>": "<Return>",
        }
        for from_key in kmap:

            def mfunc(event, key=from_key):
                self.event_generate(kmap[key], **{"state": event.state})
                return "break"

            try:
                self.bind(from_key, mfunc)
            except TclError:
                pass

    def _bind_movement_aids(self):
        self.bind("<Home>", self.perform_smart_home, True)
        self.bind("<Left>", self.move_to_edge_if_selection(0), True)
        self.bind("<Right>", self.move_to_edge_if_selection(1), True)
        self.bind("<Next>", self.perform_page_down, True)
        self.bind("<Prior>", self.perform_page_up, True)

    def _bind_selection_aids(self):
        self.bind("<Command-a>" if _running_on_mac() else "<Control-a>", self.select_all, True)

    def _bind_undo_aids(self):
        self.bind("<<Undo>>", self._on_undo, True)
        self.bind("<<Redo>>", self._on_redo, True)
        self.bind("<<Cut>>", self._on_cut, True)
        self.bind("<<Copy>>", self._on_copy, True)
        self.bind("<<Paste>>", self._on_paste, True)
        self.bind("<FocusIn>", self._on_get_focus, True)
        self.bind("<FocusOut>", self._on_lose_focus, True)
        self.bind("<Key>", self._on_key_press, True)
        self.bind("<1>", self._on_mouse_click, True)
        self.bind("<2>", self._on_mouse_click, True)
        self.bind("<3>", self._on_mouse_click, True)

        if _running_on_x11() or _running_on_mac():

            def custom_redo(event):
                self.event_generate("<<Redo>>")
                return "break"

            self.bind("<Control-y>", custom_redo, True)

    def _redirect_ctrld(self, event):
        # I want to disable the deletion effect of Ctrl-D in the text but still
        # keep the event for other purposes
        self.event_generate("<<CtrlDInText>>")
        return "break"

    def _redirect_shift_control_space(self, event):
        # I want to disable the effect of Shift-Control-space in the text but still
        # keep the event for other purposes
        self.event_generate("<<ShiftControlSpaceInText>>")
        return "break"

    def _redirect_ctrlt(self, event):
        # I want to disable the swap effect of Ctrl-T in the text but still
        # keep the event for other purposes
        self.event_generate("<<CtrlTInText>>")
        return "break"

    def _redirect_ctrlf(self, event):
        # I want to disable the Tk effect of Ctrl-T in the text but still
        # keep the event for other purposes
        self.event_generate("<<CtrlFInText>>")
        return "break"

    def tag_reset(self, tag_name):
        empty_conf = {key: "" for key in self.tag_configure(tag_name)}
        self.tag_configure(empty_conf)

    def select_lines(self, first_line, last_line):
        self.tag_remove("sel", "1.0", tk.END)
        self.tag_add("sel", "%s.0" % first_line, "%s.end" % last_line)

    def delete_word_left(self, event):
        self.event_generate("<Meta-Delete>")
        self.edit_separator()
        return "break"

    def delete_word_right(self, event):
        self.event_generate("<Meta-d>")
        self.edit_separator()
        return "break"

    def perform_smart_backspace(self, event):
        self._log_keypress_for_undo(event)

        text = self
        first, last = self.get_selection_indices()
        if first and last:
            text.delete(first, last)
            text.mark_set("insert", first)
            return "break"
        # Delete whitespace left, until hitting a real char or closest
        # preceding virtual tab stop.
        chars = text.get("insert linestart", "insert")
        if chars == "":
            if text.compare("insert", ">", "1.0"):
                # easy: delete preceding newline
                text.delete("insert-1c")
            else:
                text.bell()  # at start of buffer
            return "break"

        if (
            chars.strip() != ""
        ):  # there are non-whitespace chars somewhere to the left of the cursor
            # easy: delete preceding real char
            text.delete("insert-1c")
            self._log_keypress_for_undo(event)
            return "break"

        # Ick.  It may require *inserting* spaces if we back up over a
        # tab character!  This is written to be clear, not fast.
        have = len(chars.expandtabs(self.tabwidth))
        assert have > 0
        want = ((have - 1) // self.indent_width) * self.indent_width
        # Debug prompt is multilined....
        # if self.context_use_ps1:
        #    last_line_of_prompt = sys.ps1.split('\n')[-1]
        # else:
        last_line_of_prompt = ""
        ncharsdeleted = 0
        while 1:
            if chars == last_line_of_prompt:
                break
            chars = chars[:-1]
            ncharsdeleted = ncharsdeleted + 1
            have = len(chars.expandtabs(self.tabwidth))
            if have <= want or chars[-1] not in " \t":
                break
        text.delete("insert-%dc" % ncharsdeleted, "insert")
        if have < want:
            text.insert("insert", " " * (want - have))
        return "break"

    def perform_midline_tab(self, event=None):
        "autocompleter can put its magic here"
        # by default
        return self.perform_dumb_tab(event)

    def perform_dumb_tab(self, event=None):
        self._log_keypress_for_undo(event)
        self.insert("insert", "\t")
        return "break"

    def perform_smart_tab(self, event=None):
        self._log_keypress_for_undo(event)

        # if intraline selection:
        #     delete it
        # elif multiline selection:
        #     do indent-region
        # else:
        #     indent one level

        first, last = self.get_selection_indices()
        if first and last:
            if index2line(first) != index2line(last):
                return self.indent_region(event)
            self.delete(first, last)
            self.mark_set("insert", first)
        prefix = self.get("insert linestart", "insert")
        raw, effective = classifyws(prefix, self.tabwidth)
        if raw == len(prefix):
            # only whitespace to the left
            self._reindent_to(effective + self.indent_width)
        else:
            # tab to the next 'stop' within or to right of line's text:
            if self.should_indent_with_tabs():
                pad = "\t"
            else:
                effective = len(prefix.expandtabs(self.tabwidth))
                n = self.indent_width
                pad = " " * (n - effective % n)
            self.insert("insert", pad)
        self.see("insert")
        return "break"

    def get_cursor_position(self):
        return map(int, self.index("insert").split("."))

    def get_line_count(self):
        return list(map(int, self.index("end-1c").split(".")))[0]

    def perform_return(self, event):
        self.insert("insert", "\n")
        return "break"

    def perform_page_down(self, event):
        # if last line is visible then go to last line
        # (by default it doesn't move then)
        try:
            last_visible_idx = self.index("@0,%d" % self.winfo_height())
            row, _ = map(int, last_visible_idx.split("."))
            line_count = self.get_line_count()

            if row == line_count or row == line_count - 1:  # otherwise tk doesn't show last line
                self.mark_set("insert", "end")
        except Exception as e:
            logger.exception("Could not perform page down", exc_info=e)

    def perform_page_up(self, event):
        # if first line is visible then go there
        # (by default it doesn't move then)
        try:
            first_visible_idx = self.index("@0,0")
            row, _ = map(int, first_visible_idx.split("."))
            if row == 1:
                self.mark_set("insert", "1.0")
        except Exception as e:
            logger.exception("Could not perform page up", exc_info=e)

    def compute_smart_home_destination_index(self):
        """Is overridden in shell"""

        line = self.get("insert linestart", "insert lineend")
        for insertpt in range(len(line)):
            if line[insertpt] not in (" ", "\t"):
                break
        else:
            insertpt = len(line)

        lineat = int(self.index("insert").split(".")[1])
        if insertpt == lineat:
            insertpt = 0
        return "insert linestart+" + str(insertpt) + "c"

    def perform_smart_home(self, event):
        if (event.state & 4) != 0 and event.keysym == "Home":
            # state&4==Control. If <Control-Home>, use the Tk binding.
            return None

        dest = self.compute_smart_home_destination_index()

        if (event.state & 1) == 0:
            # shift was not pressed
            self.tag_remove("sel", "1.0", "end")
        else:
            if not self.index_sel_first():
                # there was no previous selection
                self.mark_set("my_anchor", "insert")
            else:
                if self.compare(self.index_sel_first(), "<", self.index("insert")):
                    self.mark_set("my_anchor", "sel.first")  # extend back
                else:
                    self.mark_set("my_anchor", "sel.last")  # extend forward
            first = self.index(dest)
            last = self.index("my_anchor")
            if self.compare(first, ">", last):
                first, last = last, first
            self.tag_remove("sel", "1.0", "end")
            self.tag_add("sel", first, last)
        self.mark_set("insert", dest)
        self.see("insert")
        return "break"

    def move_to_edge_if_selection(self, edge_index):
        """Cursor move begins at start or end of selection

        When a left/right cursor key is pressed create and return to Tkinter a
        function which causes a cursor move from the associated edge of the
        selection.
        """

        def move_at_edge(event):
            if (
                self.has_selection() and (event.state & 5) == 0
            ):  # no shift(==1) or control(==4) pressed
                try:
                    self.mark_set("insert", ("sel.first+1c", "sel.last-1c")[edge_index])
                except tk.TclError:
                    pass

        return move_at_edge

    def perform_tab(self, event=None):
        self._log_keypress_for_undo(event)
        if event.state & 0x0001:  # shift is pressed (http://stackoverflow.com/q/32426250/261181)
            return self.dedent_region(event)
        else:
            # check whether there are letters before cursor on this line
            index = self.index("insert")
            left_text = self.get(index + " linestart", index)
            if left_text.strip() == "" or self.has_selection():
                return self.perform_smart_tab(event)
            else:
                return self.perform_midline_tab(event)

    def indent_region(self, event=None):
        return self._change_indentation(True)

    def dedent_region(self, event=None):
        return self._change_indentation(False)

    def _change_indentation(self, increase=True):
        head, tail, chars, lines = self._get_region()

        # Text widget plays tricks if selection ends on last line
        # and content doesn't end with empty line,
        text_last_line = index2line(self.index("end-1c"))
        sel_last_line = index2line(tail)
        if sel_last_line >= text_last_line:
            while not self.get(head, "end").endswith("\n\n"):
                self.insert("end", "\n")

        for pos in range(len(lines)):
            line = lines[pos]
            if line:
                raw, effective = classifyws(line, self.tabwidth)
                if increase:
                    effective = effective + self.indent_width
                else:
                    effective = max(effective - self.indent_width, 0)
                lines[pos] = self._make_blanks(effective) + line[raw:]
        self._set_region(head, tail, chars, lines)
        return "break"

    def select_all(self, event):
        self.tag_remove("sel", "1.0", tk.END)
        self.tag_add("sel", "1.0", tk.END)

    def set_read_only(self, value):
        if value == self.is_read_only():
            return

        TweakableText.set_read_only(self, value)
        self._reload_theme_options()
        if self._should_tag_current_line:
            self._tag_current_line()

    def _reindent_to(self, column):
        # Delete from beginning of line to insert point, then reinsert
        # column logical (meaning use tabs if appropriate) spaces.
        if self.compare("insert linestart", "!=", "insert"):
            self.delete("insert linestart", "insert")
        if column:
            self.insert("insert", self._make_blanks(column))

    def _get_region(self):
        first, last = self.get_selection_indices()
        if first and last:
            head = self.index(first + " linestart")
            tail = self.index(last + "-1c lineend +1c")
        else:
            head = self.index("insert linestart")
            tail = self.index("insert lineend +1c")
        chars = self.get(head, tail)
        lines = chars.split("\n")
        return head, tail, chars, lines

    def _set_region(self, head, tail, chars, lines):
        newchars = "\n".join(lines)
        if newchars == chars:
            self.bell()
            return
        self.tag_remove("sel", "1.0", "end")
        self.mark_set("insert", head)
        self.delete(head, tail)
        self.insert(head, newchars)
        self.tag_add("sel", head, "insert")

    def _log_keypress_for_undo(self, e):
        if e is None:
            return

        # NB! this may not execute if the event is cancelled in another handler
        event_kind = self._get_event_kind(e)

        if (
            event_kind != self._last_event_kind
            or e.char in ("\r", "\n", " ", "\t")
            or e.keysym in ["Return", "KP_Enter"]
            or time.time() - self._last_key_time > 2
        ):
            self.edit_separator()

        self._last_event_kind = event_kind
        self._last_key_time = time.time()

    def _get_event_kind(self, event):
        if event.keysym in ("BackSpace", "Delete"):
            return "delete"
        elif event.char:
            return "insert"
        else:
            # eg. e.keysym in ("Left", "Up", "Right", "Down", "Home", "End", "Prior", "Next"):
            return "other_key"

    def _make_blanks(self, n):
        # Make string that displays as n leading blanks.
        if self.should_indent_with_tabs():
            ntabs, nspaces = divmod(n, self.tabwidth)
            return "\t" * ntabs + " " * nspaces
        else:
            return " " * n

    def _on_undo(self, e):
        self._last_event_kind = "undo"

    def _on_redo(self, e):
        self._last_event_kind = "redo"

    def _on_cut(self, e):
        self._last_event_kind = "cut"
        self.edit_separator()

    def _on_copy(self, e):
        self._last_event_kind = "copy"
        self.edit_separator()

    def _on_paste(self, e):
        if self.is_read_only():
            return

        try:
            if self.has_selection():
                self.direct_delete("sel.first", "sel.last")
        except Exception:
            pass

        self._last_event_kind = "paste"
        self.edit_separator()
        self.see("insert")
        self.after_idle(lambda: self.see("insert"))

    def _on_get_focus(self, e):
        self._last_event_kind = "get_focus"
        self.edit_separator()

    def _on_lose_focus(self, e):
        self._last_event_kind = "lose_focus"
        self.edit_separator()

    def _on_key_press(self, e):
        return self._log_keypress_for_undo(e)

    def _on_mouse_click(self, event):
        self.edit_separator()

    def _tag_current_line(self, event=None):
        self.tag_remove("current_line", "1.0", "end")

        # Let's show current line only with readable text
        # (this fits well with Thonny debugger,
        # otherwise debugger focus box and current line interact in an ugly way)
        if self._should_tag_current_line and not self.is_read_only():
            # we may be on the same line as with prev event but tag needs extension
            lineno = int(self.index("insert").split(".")[0])
            self.tag_add("current_line", str(lineno) + ".0", str(lineno + 1) + ".0")

    def on_secondary_click(self, event=None):
        "Use this for invoking context menu"
        self.focus_set()
        if event:
            self.mark_set("insert", "@%d,%d" % (event.x, event.y))

    def _reload_theme_options(self, event=None):
        style = ttk.Style()

        states = []
        if self.is_read_only():
            states.append("readonly")

        # Following crashes when a combobox is focused
        # if self.focus_get() == self:
        #    states.append("focus")

        if "background" not in self._initial_configuration:
            background = style.lookup(self._style, "background", states)
            if background:
                self.configure(background=background)

        if "foreground" not in self._initial_configuration:
            foreground = style.lookup(self._style, "foreground", states)
            if foreground:
                self.configure(foreground=foreground)
                self.configure(insertbackground=foreground)

    def _insert_untypable_characters_on_windows(self, event):
        if event.state == 131084:  # AltGr or Ctrl+Alt
            lang_id = get_keyboard_language()
            char = _windows_altgr_chars_by_lang_id_and_keycode.get(lang_id, {}).get(
                event.keycode, None
            )
            if char is not None:
                self.insert("insert", char)

    def destroy(self):
        self.unbind("<<ThemeChanged>>", self._ui_theme_change_binding)
        super().destroy()

    def direct_insert(self, index, chars, tags=None, **kw):
        super().direct_insert(index, chars, tags, **kw)


class TextFrame(ttk.Frame):
    "Decorates text with scrollbars, listens for theme changes"

    def __init__(
        self,
        master,
        text_class=EnhancedText,
        horizontal_scrollbar=True,
        vertical_scrollbar=True,
        vertical_scrollbar_class=ttk.Scrollbar,
        horizontal_scrollbar_class=ttk.Scrollbar,
        vertical_scrollbar_style=None,
        horizontal_scrollbar_style=None,
        borderwidth=0,
        relief="sunken",
        **text_options,
    ):
        ttk.Frame.__init__(self, master=master, borderwidth=borderwidth, relief=relief)

        final_text_options = {
            "borderwidth": 0,
            "insertwidth": 2,
            "spacing1": 0,
            "spacing3": 0,
            "highlightthickness": 0,
            "inactiveselectbackground": "gray",
            "padx": 5,
            "pady": 5,
        }
        final_text_options.update(text_options)
        self.text = text_class(self, **final_text_options)

        if vertical_scrollbar:
            self._vbar = vertical_scrollbar_class(
                self, orient=tk.VERTICAL, style=vertical_scrollbar_style
            )
            self._vbar["command"] = self._vertical_scroll
            self.text["yscrollcommand"] = self._vertical_scrollbar_update
        else:
            self._vbar = None

        if horizontal_scrollbar:
            self._hbar = horizontal_scrollbar_class(
                self, orient=tk.HORIZONTAL, style=horizontal_scrollbar_style
            )
            self._hbar["command"] = self._horizontal_scroll
            self.text["xscrollcommand"] = self._horizontal_scrollbar_update
        else:
            self._hbar = None

        self.grid_main_widgets()
        self.columnconfigure(1, weight=1)
        self.rowconfigure(0, weight=1)

        self._ui_theme_change_binding = self.bind(
            "<<ThemeChanged>>", self._reload_theme_options, True
        )

        # TODO: add context menu?

        self._reload_theme_options(None)

    def grid_main_widgets(self):
        self.text.grid(row=0, column=1, sticky=tk.NSEW)
        if self._vbar:
            self._vbar.grid(row=0, column=2, sticky=tk.NSEW)
        if self._hbar:
            self._hbar.grid(row=1, column=0, sticky=tk.NSEW, columnspan=3)

    def focus_set(self):
        self.text.focus_set()

    def _vertical_scrollbar_update(self, *args):
        if not hasattr(self, "_vbar"):
            return

        self._vbar.set(*args)
        self.text.event_generate("<<VerticalScroll>>")

    def _horizontal_scrollbar_update(self, *args):
        self._hbar.set(*args)

    def _vertical_scroll(self, *args):
        self.text.yview(*args)
        self.text.event_generate("<<VerticalScroll>>")

    def _horizontal_scroll(self, *args):
        self.text.xview(*args)

    def _reload_theme_options(self, event=None):
        pass

    def destroy(self):
        self.unbind("<<ThemeChanged>>", self._ui_theme_change_binding)
        super().destroy()


class EnhancedTextFrame(TextFrame):
    "Adds line numbers and print margin"

    def __init__(
        self,
        master,
        line_numbers=False,
        line_length_margin=0,
        first_line_number=1,
        text_class=EnhancedText,
        horizontal_scrollbar=True,
        vertical_scrollbar=True,
        vertical_scrollbar_class=ttk.Scrollbar,
        horizontal_scrollbar_class=ttk.Scrollbar,
        vertical_scrollbar_style=None,
        horizontal_scrollbar_style=None,
        borderwidth=0,
        relief="sunken",
        gutter_background="#e0e0e0",
        gutter_foreground="#999999",
        **text_options,
    ):
        self._gutter = None

        super().__init__(
            master,
            text_class=text_class,
            horizontal_scrollbar=horizontal_scrollbar,
            vertical_scrollbar=vertical_scrollbar,
            vertical_scrollbar_class=vertical_scrollbar_class,
            horizontal_scrollbar_class=horizontal_scrollbar_class,
            vertical_scrollbar_style=vertical_scrollbar_style,
            horizontal_scrollbar_style=horizontal_scrollbar_style,
            borderwidth=borderwidth,
            relief=relief,
            **text_options,
        )

        self._recommended_line_length = line_length_margin

        self._gutter = tk.Text(
            self,
            width=5,
            padx=0,
            pady=5,
            highlightthickness=0,
            bd=0,
            takefocus=False,
            font=self.text["font"],
            background="#e0e0e0",
            foreground=gutter_foreground,
            selectbackground=gutter_background,
            selectforeground=gutter_foreground,
            cursor="arrow",
            state="disabled",
            undo=False,
            wrap="none",
        )

        if "height" in text_options:
            self._gutter.configure(height=text_options["height"])

        self._gutter_is_gridded = False
        self._gutter.bind("<Double-Button-1>", self.on_gutter_double_click, True)
        self._gutter.bind("<ButtonRelease-1>", self.on_gutter_click, True)
        self._gutter.bind("<Button-1>", self.on_gutter_click, True)
        self._gutter.bind("<Button1-Motion>", self.on_gutter_motion, True)
        self._gutter["yscrollcommand"] = self._gutter_scroll

        # need tags for justifying and rmargin
        self._gutter.tag_configure("content", justify="right", rmargin=3)

        # gutter will be gridded later
        assert first_line_number is not None
        self._first_line_number = first_line_number
        self.set_gutter_visibility(line_numbers)

        margin_line_color = ttk.Style().lookup("Gutter", "background", default="LightGray")
        self._margin_line = tk.Canvas(
            self.text,
            borderwidth=0,
            width=1,
            height=2000,
            highlightthickness=0,
            background=margin_line_color,
        )
        self.update_margin_line()

        self.text.bind("<<TextChange>>", self._text_changed, True)
        self.text.bind("<<CursorMove>>", self._cursor_moved, True)

        self._reload_gutter_theme_options()

    def set_gutter_visibility(self, value):
        if value and not self._gutter_is_gridded:
            self._gutter.grid(row=0, column=0, sticky=tk.NSEW)
            self._gutter_is_gridded = True
        elif not value and self._gutter_is_gridded:
            self._gutter.grid_forget()
            self._gutter_is_gridded = False
        else:
            return

        """
        # insert first line number (NB! Without trailing linebreak. See update_gutter)
        self._gutter.config(state="normal")
        self._gutter.delete("1.0", "end")
        for content, tags in self.compute_gutter_line(self._first_line_number):
            self._gutter.insert("end", content, ("content",) + tags)
        self._gutter.config(state="disabled")
        """
        self.update_gutter(True)

    def set_line_length_margin(self, value):
        self._recommended_line_length = value
        self.update_margin_line()

    def _gutter_scroll(self, *args):
        if not hasattr(self, "_vbar"):
            return

        try:
            self._vbar.set(*args)
            self.text.yview(tk.MOVETO, args[0])
        except TclError:
            pass

    def _text_changed(self, event):
        self.update_gutter()

    def _cursor_moved(self, event):
        self._update_gutter_active_line()

    def update_gutter(self, clean=False):
        if clean:
            self._gutter.config(state="normal")
            self._gutter.delete("1.0", "end")
            # need to add first item separately, because Text can't report 0 rows
            for content, tags in self.compute_gutter_line(self._first_line_number):
                self._gutter.insert("end-1c", content, tags + ("content",))

            self._gutter.config(state="disabled")

        text_line_count = int(self.text.index("end").split(".")[0])
        gutter_line_count = int(self._gutter.index("end").split(".")[0])

        if text_line_count != gutter_line_count:
            self._gutter.config(state="normal")

            # NB! Text acts weird with last symbol
            # (don't really understand whether it automatically keeps a newline there or not)
            # Following seems to ensure both Text-s have same height
            if text_line_count > gutter_line_count:
                delta = text_line_count - gutter_line_count
                start = gutter_line_count + self._first_line_number - 1

                if not clean and text_line_count > 10 and gutter_line_count < 3:
                    # probably initial load, do bulk insert
                    parts = []
                    for i in range(start, start + delta):
                        parts.append("\n")
                        for content, tags in self.compute_gutter_line(i, plain=True):
                            parts.append(content)

                    self._gutter.insert("end-1c", "".join(parts), ("content",) + tags)
                else:
                    for i in range(start, start + delta):
                        self._gutter.insert("end-1c", "\n", ("content",))
                        for content, tags in self.compute_gutter_line(i):
                            self._gutter.insert("end-1c", content, ("content",) + tags)
            else:
                self._gutter.delete(line2index(text_line_count) + "-1c", "end-1c")

            self._gutter.config(state="disabled")

        # synchronize gutter scroll position with text
        # https://mail.python.org/pipermail/tkinter-discuss/2010-March/002197.html
        first, _ = self.text.yview()
        self._gutter.yview_moveto(first)
        self._update_gutter_active_line()

        if text_line_count > 9998:
            self._gutter.configure(width=7)
        elif text_line_count > 998:
            self._gutter.configure(width=6)

    def _update_gutter_active_line(self):
        self._gutter.tag_remove("active", "1.0", "end")
        insert = self.text.index("insert")
        self._gutter.tag_add("active", insert + " linestart", insert + " lineend")

    def compute_gutter_line(self, lineno, plain=False):
        yield str(lineno), ()

    def update_margin_line(self):
        if self._recommended_line_length == 0:
            self._margin_line.place_forget()
        else:
            try:
                self.text.update_idletasks()
                # How far left has text been scrolled
                first_visible_idx = self.text.index("@0,0")
                first_visible_col = int(first_visible_idx.split(".")[1])
                bbox = self.text.bbox(first_visible_idx)
                first_visible_col_x = bbox[0]

                margin_line_visible_col = self._recommended_line_length - first_visible_col
                delta = first_visible_col_x
            except Exception:
                # fall back to ignoring scroll position
                margin_line_visible_col = self._recommended_line_length
                delta = 0

            if margin_line_visible_col > -1:
                try:
                    x = (
                        get_text_font(self.text).measure((margin_line_visible_col - 1) * "M")
                        + delta
                        + self.text["padx"]
                    )
                except TclError:
                    logger.exception("Could not measure text")
                    x = -10
            else:
                x = -10

            # print(first_visible_col, first_visible_col_x)

            self._margin_line.place(y=-10, x=x)

    def on_gutter_click(self, event=None):
        try:
            linepos = self._gutter.index("@%s,%s" % (event.x, event.y)).split(".")[0]
            self.text.mark_set("insert", "%s.0" % linepos)
            self._gutter.mark_set("gutter_selection_start", "%s.0" % linepos)
            if (
                event.type == "4"
            ):  # In Python 3.6 you can use tk.EventType.ButtonPress instead of "4"
                self.text.tag_remove("sel", "1.0", "end")
        except tk.TclError:
            logger.exception("on_gutter_click")

    def on_gutter_double_click(self, event=None):
        try:
            self._gutter.mark_unset("gutter_selection_start")
            self.text.tag_remove("sel", "1.0", "end")
            self._gutter.tag_remove("sel", "1.0", "end")
        except tk.TclError:
            logger.exception("on_gutter_click")

    def on_gutter_motion(self, event=None):
        try:
            if "gutter_selection_start" not in self._gutter.mark_names():
                return
            linepos = int(self._gutter.index("@%s,%s" % (event.x, event.y)).split(".")[0])
            gutter_selection_start = int(self._gutter.index("gutter_selection_start").split(".")[0])
            self.text.select_lines(
                min(gutter_selection_start, linepos), max(gutter_selection_start - 1, linepos - 1)
            )
            self.text.mark_set("insert", "%s.0" % linepos)
            self.text.focus_set()
        except tk.TclError:
            logger.exception("on_gutter_motion")

    def _vertical_scrollbar_update(self, *args):
        if not hasattr(self, "_vbar"):
            return

        super()._vertical_scrollbar_update(*args)
        self._gutter.yview(tk.MOVETO, args[0])

    def _horizontal_scrollbar_update(self, *args):
        super()._horizontal_scrollbar_update(*args)
        self.update_margin_line()

    def _vertical_scroll(self, *args):
        super()._vertical_scroll(*args)
        self._gutter.yview(*args)

    def _horizontal_scroll(self, *args):
        super()._horizontal_scroll(*args)
        self.update_margin_line()

    def _reload_theme_options(self, event=None):
        super()._reload_theme_options(event)
        if self._gutter is not None:
            self._reload_gutter_theme_options(event)

    def _reload_gutter_theme_options(self, event=None):
        style = ttk.Style()
        background = style.lookup("GUTTER", "background")
        if background:
            self._gutter.configure(background=background, selectbackground=background)
            self._margin_line.configure(background=background)

        foreground = style.lookup("GUTTER", "foreground")
        if foreground:
            self._gutter.configure(foreground=foreground, selectforeground=foreground)


def get_text_font(text):
    font = text["font"]
    if isinstance(font, str):
        return tkfont.nametofont(font)
    else:
        return font


def classifyws(s, tabwidth):
    raw = effective = 0
    for ch in s:
        if ch == " ":
            raw = raw + 1
            effective = effective + 1
        elif ch == "\t":
            raw = raw + 1
            effective = (effective // tabwidth + 1) * tabwidth
        else:
            break
    return raw, effective


def index2line(index):
    return int(float(index))


def line2index(line):
    return str(float(line))


def fixwordbreaks(root):
    # Adapted from idlelib.EditorWindow (Python 3.4.2)
    # Modified to include non-ascii chars

    # Make sure that Tk's double-click and next/previous word
    # operations use our definition of a word (i.e. an identifier)
    root.tk.call("tcl_wordBreakAfter", "a b", 0)  # make sure word.tcl is loaded
    root.tk.call("set", "tcl_wordchars", r"\w")
    root.tk.call("set", "tcl_nonwordchars", r"\W")


def rebind_control_a(root):
    # Tk 8.6 has <<SelectAll>> event but 8.5 doesn't
    # http://stackoverflow.com/questions/22907200/remap-default-keybinding-in-tkinter
    def control_a(event):
        widget = event.widget
        if isinstance(widget, tk.Text):
            widget.tag_remove("sel", "1.0", "end")
            widget.tag_add("sel", "1.0", "end")

    root.bind_class("Text", "<Control-a>", control_a)


def _running_on_mac():
    return tk._default_root.call("tk", "windowingsystem") == "aqua"


def _running_on_x11():
    return tk._default_root.call("tk", "windowingsystem") == "x11"


def get_keyboard_language():
    # https://stackoverflow.com/a/42047820/261181
    if sys.platform != "win32":
        raise NotImplementedError("Can provide keyboard language only on Windows")

    import ctypes

    user32 = ctypes.WinDLL("user32", use_last_error=True)
    curr_window = user32.GetForegroundWindow()
    thread_id = user32.GetWindowThreadProcessId(curr_window, 0)
    # Made up of 0xAAABBBB, AAA = HKL (handle object) & BBBB = language ID
    klid = user32.GetKeyboardLayout(thread_id)
    # Language ID -> low 10 bits, Sub-language ID -> high 6 bits
    # Extract language ID from KLID
    lid = klid & (2**16 - 1)

    return lid


_windows_altgr_chars_by_lang_id_and_keycode = {
    # https://docs.microsoft.com/en-us/windows/desktop/intl/language-identifier-constants-and-strings
    0x0425: {191: "^"}  # AltGr+Ä
}
