<commit_before>r"""Tabs as in browser tabs, not \t characters."""
from __future__ import annotations

import collections
import dataclasses
import hashlib
import importlib
import itertools
import logging
import os
import tkinter
import traceback
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Any, Callable, Iterable, NamedTuple, Optional, Sequence, Type, TypeVar

from pygments.lexer import LexerMeta
from pygments.lexers import TextLexer

from porcupine import _state, settings, textutils, utils
from porcupine.settings import global_settings

log = logging.getLogger(__name__)
_flatten = itertools.chain.from_iterable
_T = TypeVar("_T")


def _find_duplicates(items: list[_T], key: Callable[[_T], str]) -> Iterable[list[_T]]:
    items_by_key: dict[str, list[_T]] = {}
    for item in items:
        items_by_key.setdefault(key(item), []).append(item)
    return [itemlist for itemlist in items_by_key.values() if len(itemlist) >= 2]


def _short_ways_to_display_path(path: Path) -> list[str]:
    parts = str(path).split(os.sep)
    return [parts[-1], parts[-2] + os.sep + parts[-1]] + [
        first_part + os.sep + "..." + os.sep + parts[-1] for first_part in parts[:-2]
    ]


class TabManager(ttk.Notebook):
    """A simple but awesome tab widget.

    This widget inherits from ``ttk.Notebook``. All widgets added to
    this should be :class:`Tab` objects.

    .. virtualevent:: NotebookTabChanged

        This runs when the user selects another tab or Porcupine does it
        for some reason. Use ``event.widget.select()`` to get the
        currently selected tab.

        This virtual event has ``Notebook`` prefixed in its name because
        it's inherited from ``ttk.Notebook`` and whoever created it
        wanted to add some boilerplate. I think something like
        ``<<SelectedTabChanged>>`` would be a better name.

        .. seealso:: :meth:`select`

    .. virtualevent:: FileSystemChanged

        Runs when a file has been saved, a command finishes running, the
        Porcupine window is focused, or there's some other reason why files on
        the disk have likely changed.

        This event is in the tab manager and not in the main window (see
        :func:`porcupine.get_main_window`), because bindings of the main window
        also get notified for the events of all child widgets, and there is
        also a tab-specific :virtevt:`~Tab.FileSystemChanged` event.

    .. method:: add(child, **kw)
    .. method:: enable_traversal()
    .. method:: forget(tab_id)
    .. method:: hide(tab_id)
    .. method:: tab(tab_id, option=None, **kw)

        Don't use these methods. They are inherited from
        ``ttk.Notebook``, and :class:`TabManager` has other methods for
        doing what is usually done with these methods.

        .. seealso::
            :meth:`add_tab`, :meth:`close_tab`, :attr:`Tab.title`
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.bind("<<NotebookTabChanged>>", self._on_tab_selected, add=True)
        self.bind("<<FileSystemChanged>>", self._on_fs_changed, add=True)
        self.winfo_toplevel().bind("<FocusIn>", self._handle_main_window_focus, add=True)

        # the string is call stack for adding callback
        self._tab_callbacks: list[tuple[Callable[[Tab], Any], str]] = []

    def _handle_main_window_focus(self, event: tkinter.Event[tkinter.Misc]) -> None:
        if event.widget is self.winfo_toplevel():
            self.event_generate("<<FileSystemChanged>>")

    def _on_tab_selected(self, junk_event: tkinter.Event[tkinter.Misc]) -> None:
        tab = self.select()
        if tab is not None:
            tab.event_generate("<<TabSelected>>")
            tab.event_generate("<<FileSystemChanged>>")

    def _on_fs_changed(self, junk_event: tkinter.Event[tkinter.Misc]) -> None:
        tab = self.select()
        if tab is not None:
            tab.event_generate("<<FileSystemChanged>>")

    def _update_tab_titles(self) -> None:
        titlelists = [list(tab.title_choices) for tab in self.tabs()]
        while True:
            did_something = False
            for conflicting_title_lists in _find_duplicates(
                titlelists, key=(lambda lizt: lizt[0].strip("*"))
            ):
                # shorten longest title lists
                maxlen = max(len(titlelist) for titlelist in conflicting_title_lists)
                if maxlen >= 2:
                    for titlelist in conflicting_title_lists:
                        if len(titlelist) == maxlen:
                            del titlelist[0]
                            did_something = True
            if not did_something:
                break

        for tab, titlelist in zip(self.tabs(), titlelists):
            self.tab(tab, text=titlelist[0])

    # fixing tkinter weirdness: some methods returns widget names as
    # strings instead of widget objects, these str() everything anyway
    # because tkinter might be fixed some day
    def select(self, tab_id: Tab | int | None = None) -> Tab | None:
        """Select the given tab as if the user clicked it.

        Usually the ``tab_id`` should be a :class:`.Tab` widget. If it is not
        given, this returns the currently selected tab or None if there are no
        tabs.
        """
        if tab_id is None:
            selected = super().select()
            if not selected:  # no tabs, selected == ''
                return None
            return self.nametowidget(str(selected))

        # the tab can be e.g. an index, that's why two super() calls
        super().select(tab_id)
        return None

    def tabs(self) -> tuple[Tab, ...]:
        """Return a tuple of tabs in the tab manager.

        This returns a tuple instead of a list for compatibility with
        tkinter.
        """
        # tkinter has a bug that makes the original tabs() return widget name
        # strings instead of widget objects
        return tuple(self.nametowidget(tab) for tab in super().tabs())

    def open_file(self, path: Path) -> FileTab | None:
        """Add a :class:`FileTab` for editing a file and select it.

        If the file can't be opened, this method displays an error to the user
        and returns ``None``.
        """

        try:
            if path.stat().st_size > 1_000_000 and not messagebox.askyesno(
                "Open big file",
                "Uhh, this file is huge!\nAre you sure you want to open it? ",
                detail=(
                    "This file is larger than 1MB. If you open it, Porcupine may be unusably slow"
                    " or require an huge amount of RAM."
                ),
            ):
                return None
        except OSError:
            pass  # no problem, reload() will handle the error

        # Add tab before loading content, so that editorconfig plugin gets a
        # chance to set the encoding into tab.settings
        tab = FileTab(self, path=path)
        existing_tab = self.add_tab(tab)
        if existing_tab != tab:
            # tab is destroyed
            assert isinstance(existing_tab, FileTab)
            existing_tab.textwidget.focus()
            return existing_tab

        if not tab.reload(undoable=False):
            self.close_tab(tab)
            return None

        tab.textwidget.mark_set("insert", "1.0")
        return tab

    def add_tab(self, tab: Tab, select: bool = True) -> Tab:<commit_msg>Fix typo in #998 (#1091)<commit_after>r"""Tabs as in browser tabs, not \t characters."""
from __future__ import annotations

import collections
import dataclasses
import hashlib
import importlib
import itertools
import logging
import os
import tkinter
import traceback
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
from typing import Any, Callable, Iterable, NamedTuple, Optional, Sequence, Type, TypeVar

from pygments.lexer import LexerMeta
from pygments.lexers import TextLexer

from porcupine import _state, settings, textutils, utils
from porcupine.settings import global_settings

log = logging.getLogger(__name__)
_flatten = itertools.chain.from_iterable
_T = TypeVar("_T")


def _find_duplicates(items: list[_T], key: Callable[[_T], str]) -> Iterable[list[_T]]:
    items_by_key: dict[str, list[_T]] = {}
    for item in items:
        items_by_key.setdefault(key(item), []).append(item)
    return [itemlist for itemlist in items_by_key.values() if len(itemlist) >= 2]


def _short_ways_to_display_path(path: Path) -> list[str]:
    parts = str(path).split(os.sep)
    return [parts[-1], parts[-2] + os.sep + parts[-1]] + [
        first_part + os.sep + "..." + os.sep + parts[-1] for first_part in parts[:-2]
    ]


class TabManager(ttk.Notebook):
    """A simple but awesome tab widget.

    This widget inherits from ``ttk.Notebook``. All widgets added to
    this should be :class:`Tab` objects.

    .. virtualevent:: NotebookTabChanged

        This runs when the user selects another tab or Porcupine does it
        for some reason. Use ``event.widget.select()`` to get the
        currently selected tab.

        This virtual event has ``Notebook`` prefixed in its name because
        it's inherited from ``ttk.Notebook`` and whoever created it
        wanted to add some boilerplate. I think something like
        ``<<SelectedTabChanged>>`` would be a better name.

        .. seealso:: :meth:`select`

    .. virtualevent:: FileSystemChanged

        Runs when a file has been saved, a command finishes running, the
        Porcupine window is focused, or there's some other reason why files on
        the disk have likely changed.

        This event is in the tab manager and not in the main window (see
        :func:`porcupine.get_main_window`), because bindings of the main window
        also get notified for the events of all child widgets, and there is
        also a tab-specific :virtevt:`~Tab.FileSystemChanged` event.

    .. method:: add(child, **kw)
    .. method:: enable_traversal()
    .. method:: forget(tab_id)
    .. method:: hide(tab_id)
    .. method:: tab(tab_id, option=None, **kw)

        Don't use these methods. They are inherited from
        ``ttk.Notebook``, and :class:`TabManager` has other methods for
        doing what is usually done with these methods.

        .. seealso::
            :meth:`add_tab`, :meth:`close_tab`, :attr:`Tab.title`
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.bind("<<NotebookTabChanged>>", self._on_tab_selected, add=True)
        self.bind("<<FileSystemChanged>>", self._on_fs_changed, add=True)
        self.winfo_toplevel().bind("<FocusIn>", self._handle_main_window_focus, add=True)

        # the string is call stack for adding callback
        self._tab_callbacks: list[tuple[Callable[[Tab], Any], str]] = []

    def _handle_main_window_focus(self, event: tkinter.Event[tkinter.Misc]) -> None:
        if event.widget is self.winfo_toplevel():
            self.event_generate("<<FileSystemChanged>>")

    def _on_tab_selected(self, junk_event: tkinter.Event[tkinter.Misc]) -> None:
        tab = self.select()
        if tab is not None:
            tab.event_generate("<<TabSelected>>")
            tab.event_generate("<<FileSystemChanged>>")

    def _on_fs_changed(self, junk_event: tkinter.Event[tkinter.Misc]) -> None:
        tab = self.select()
        if tab is not None:
            tab.event_generate("<<FileSystemChanged>>")

    def _update_tab_titles(self) -> None:
        titlelists = [list(tab.title_choices) for tab in self.tabs()]
        while True:
            did_something = False
            for conflicting_title_lists in _find_duplicates(
                titlelists, key=(lambda lizt: lizt[0].strip("*"))
            ):
                # shorten longest title lists
                maxlen = max(len(titlelist) for titlelist in conflicting_title_lists)
                if maxlen >= 2:
                    for titlelist in conflicting_title_lists:
                        if len(titlelist) == maxlen:
                            del titlelist[0]
                            did_something = True
            if not did_something:
                break

        for tab, titlelist in zip(self.tabs(), titlelists):
            self.tab(tab, text=titlelist[0])

    # fixing tkinter weirdness: some methods returns widget names as
    # strings instead of widget objects, these str() everything anyway
    # because tkinter might be fixed some day
    def select(self, tab_id: Tab | int | None = None) -> Tab | None:
        """Select the given tab as if the user clicked it.

        Usually the ``tab_id`` should be a :class:`.Tab` widget. If it is not
        given, this returns the currently selected tab or None if there are no
        tabs.
        """
        if tab_id is None:
            selected = super().select()
            if not selected:  # no tabs, selected == ''
                return None
            return self.nametowidget(str(selected))

        # the tab can be e.g. an index, that's why two super() calls
        super().select(tab_id)
        return None

    def tabs(self) -> tuple[Tab, ...]:
        """Return a tuple of tabs in the tab manager.

        This returns a tuple instead of a list for compatibility with
        tkinter.
        """
        # tkinter has a bug that makes the original tabs() return widget name
        # strings instead of widget objects
        return tuple(self.nametowidget(tab) for tab in super().tabs())

    def open_file(self, path: Path) -> FileTab | None:
        """Add a :class:`FileTab` for editing a file and select it.

        If the file can't be opened, this method displays an error to the user
        and returns ``None``.
        """

        try:
            if path.stat().st_size > 1_000_000 and not messagebox.askyesno(
                "Opening large file",
                "Uhh, this file is huge!\nAre you sure you want to open it? ",
                detail=(
                    "This file is larger than 1MB. If you open it, Porcupine may be unusably slow"
                    " or require a large amount of RAM."
                ),
            ):
                return None
        except OSError:
            pass  # no problem, reload() will handle the error

        # Add tab before loading content, so that editorconfig plugin gets a
        # chance to set the encoding into tab.settings
        tab = FileTab(self, path=path)
        existing_tab = self.add_tab(tab)
        if existing_tab != tab:
            # tab is destroyed
            assert isinstance(existing_tab, FileTab)
            existing_tab.textwidget.focus()
            return existing_tab

        if not tab.reload(undoable=False):
            self.close_tab(tab)
            return None

        tab.textwidget.mark_set("insert", "1.0")
        return tab

    def add_tab(self, tab: Tab, select: bool = True) -> Tab:
{'fraction_non_alphanumeric': 0.09900066622251832, 'fraction_numerical': 0.0031312458361092603, 'mean_word_length': 2.4046268995237017, 'pattern_counts': {'":': 0, '<': 33, '<?xml version=': 0, '>': 61, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '575342', 'n_tokens_mistral': 4330, 'n_tokens_neox': 4151, 'n_words': 1457}