# @+leo-ver=5-thin
# @+node:ekr.20140907131341.18707: * @file ../plugins/qt_tree.py
"""Leo's Qt tree class."""

# @+<< qt_tree imports >>
# @+node:ekr.20140907131341.18709: ** << qt_tree imports >>
from __future__ import annotations
from collections.abc import Callable
import re
import time
from typing import Any, TYPE_CHECKING
from leo.core.leoQt import QtCore, QtGui, QtWidgets
from leo.core.leoQt import EndEditHint, Format, ItemFlag, KeyboardModifier
from leo.core import leoGlobals as g
from leo.core import leoFrame
from leo.core import leoPlugins  # Uses leoPlugins.TryNext.
from leo.plugins import qt_text

# @-<< qt_tree imports >>
# @+<< qt_tree annotations >>
# @+node:ekr.20220417193741.1: ** << qt_tree annotations >>
if TYPE_CHECKING:  # pragma: no cover
    from typing import TypeAlias  # Requires Python 3.12+
    from leo.core.leoCommands import Commands as Cmdr
    from leo.core.leoFrame import LeoQTreeWidget
    from leo.core.leoGui import LeoKeyEvent
    from leo.core.leoNodes import Position, VNode
    from leo.plugins.qt_frame import LeoQtFrame
    from leo.plugins.qt_text import QHeadlineWrapper

    QLineEdit = QtWidgets.QLineEdit
    QIcon = QtGui.QIcon
    QPoint = QtCore.QPoint
    QTreeWidgetItem: TypeAlias = QtWidgets.QTreeWidgetItem
    QWidget = QtWidgets.QWidget


# @-<< qt_tree annotations >>
# @+others
# @+node:ekr.20160514120051.1: ** class LeoQtTree
class LeoQtTree(leoFrame.LeoTree):
    """Leo Qt tree class"""

    # @+others
    # @+node:ekr.20110605121601.18405: *3*  LeoQtTree.__init__
    def __init__(self, c: Cmdr, frame: LeoQtFrame) -> None:  # Frame is a LeoQtFrame.
        """Ctor for the LeoQtTree class."""
        super().__init__(frame)
        self.c = c
        # Widget independent status ivars...
        self.prev_v = None
        self.redrawCount = 0  # Count for debugging.
        self.revertHeadline = None  # Previous headline text for abortEditLabel.
        self.busy = False
        # Associating items with position and vnodes...
        self.items: list[QTreeWidgetItem] = []
        self.item2positionDict: dict[str, Position] = {}  # Keys are gnxs.
        self.item2vnodeDict: dict[str, VNode] = {}  # Keys are gnxs.
        # keys are gnxs, values are declutter generated icons
        self.nodeIconsDict: dict[str, list[str]] = {}
        self.position2itemDict: dict[str, QTreeWidgetItem] = {}  # Keys are gnxs.
        self.vnode2itemsDict: dict[VNode, list[QTreeWidgetItem]] = {}  # values are lists of items.
        # keys are native QLineEdit widgets, values are wrappers.
        self.editWidgetsDict: dict[QLineEdit, QHeadlineWrapper] = {}
        self.reloadSettings()
        # Components...
        self.canvas = self  # Required! An official ivar used by Leo's core.
        self.treeWidget: LeoQTreeWidget = frame.top.treeWidget
        w = self.treeWidget
        # Declutter data...
        # list of pairs of patterns for decluttering
        self.declutter_patterns: list[tuple[Any, Any]] = None
        self.declutter_data: dict[Any, Any] = {}
        self.loaded_images: dict[str, QIcon] = {}

        if 0:  # None of this works.
            # @+<< Drag and drop >>
            # @+node:ekr.20220913074246.1: *4* << Drag and drop >>
            w.setDragEnabled(True)
            w.viewport().setAcceptDrops(True)
            w.showDropIndicator = True
            w.setAcceptDrops(True)
            w.setDragDropMode(w.InternalMove)
            if 1:  # Does not work

                def dropMimeData(
                    self, data: str, action: str, row: str, col: str, parent: str
                ) -> None:
                    g.trace()

                # w.dropMimeData = dropMimeData

                def mimeData(self, indexes: str) -> None:
                    g.trace()
            # @-<< Drag and drop >>

        # Early inits...
        try:
            w.headerItem().setHidden(True)
        except Exception:
            pass
        n = c.config.getInt('icon-height') or 16
        w.setIconSize(QtCore.QSize(160, n))

    # @+node:ekr.20110605121601.18406: *3*  LeoQtTree.initAfterLoad
    def initAfterLoad(self) -> None:
        """Do late-state inits."""
        # Called by Leo's core.
        c = self.c
        # w = c.frame.top
        tw = self.treeWidget
        tw.itemDoubleClicked.connect(self.onItemDoubleClicked)
        tw.itemClicked.connect(self.onItemClicked)
        if self.use_mouse_expand_gestures:
            tw.setMouseTracking(True)
            tw.itemEntered.connect(self.onItemEntered)
        tw.itemSelectionChanged.connect(self.onTreeSelect)
        tw.itemCollapsed.connect(self.onItemCollapsed)
        tw.itemExpanded.connect(self.onItemExpanded)
        tw.customContextMenuRequested.connect(self.onContextMenu)
        # tw.onItemChanged.connect(self.onItemChanged)
        g.app.gui.setFilter(c, tw, self, tag='tree')
        # 2010/01/24: Do not set this here.
        # The read logic sets c.changed to indicate nodes have changed.
        # c.clearChanged()

    # @+node:ekr.20110605121601.17871: *3*  LeoQtTree.reloadSettings
    def reloadSettings(self) -> None:
        """LeoQtTree."""
        c = self.c
        self.auto_edit = c.config.getBool('single-click-auto-edits-headline', False)
        self.enable_drag_messages = c.config.getBool("enable-drag-messages")
        self.select_all_text_when_editing_headlines = c.config.getBool(
            'select_all_text_when_editing_headlines'
        )
        self.stayInTree = c.config.getBool('stayInTreeAfterSelect')
        self.use_chapters = c.config.getBool('use-chapters')
        self.use_declutter = c.config.getBool('tree-declutter', default=False)
        self.use_mouse_expand_gestures = c.config.getBool(
            'use-mouse-expand-gestures', default=False
        )

    # @+node:ekr.20110605121601.17866: *3* LeoQtTree.getName
    def getName(self) -> str:
        """Return the name of this widget: must start with "canvas"."""
        return 'canvas(tree)'

    # @+node:ekr.20110605121601.17868: *3* LeoQtTree: Debugging & tracing
    def error(self, s: str) -> None:
        if not g.unitTesting:
            g.trace('LeoQtTree Error: ', s, g.callers())

    def traceItem(self, item: QTreeWidgetItem) -> str:
        if item:
            # A QTreeWidgetItem.
            return f"item {id(item)}: {self.getItemText(item)}"
        return '<no item>'

    # @+node:ekr.20110605121601.17872: *3* LeoQtTree: Drawing
    # @+node:ekr.20110605121601.18408: *4* LeoQtTree.clear
    def clear(self) -> None:
        """Clear all widgets in the tree."""
        w = self.treeWidget
        w.clear()

    # @+node:ekr.20110605121601.17873: *4* LeoQtTree.full_redraw & helpers
    def full_redraw(self, p: Position = None) -> Position:
        """
        Redraw all visible nodes of the tree.
        Preserve the vertical scrolling unless scroll is True.
        """
        c = self.c
        if g.app.disable_redraw:
            return None
        if self.busy:
            return None
        # Cancel the delayed redraw request.
        c.requestLaterRedraw = False
        if not p:
            p = c.currentPosition()
        elif c.hoistStack and p.h.startswith('@chapter') and p.hasChildren():
            # Make sure the current position is visible.
            # Part of fix of bug 875323: Hoist an @chapter node leaves a non-visible node selected.
            p = p.firstChild()
            c.frame.tree.select(p)
            c.setCurrentPosition(p)
        else:
            c.setCurrentPosition(p)
        assert not self.busy, g.callers()
        self.redrawCount += 1
        self.initData()
        try:
            self.busy = True
            self.drawTopTree(p)
        finally:
            self.busy = False
        self.setItemForCurrentPosition()
        return p  # Return the position, which may have changed.

    # Compatibility

    # mypy complains that there is a mismatch with the base redraw method.
    redraw = full_redraw  # type:ignore
    redraw_now = full_redraw  # type:ignore

    # @+node:tbrown.20150807090639.1: *5* LeoQtTree.declutter_node & helpers
    def declutter_node(self, c: Cmdr, v: VNode, item: QTreeWidgetItem) -> QIcon:
        """declutter_node - change the appearance of a node

        :param commander c: commander containing node
        :param position p: position of node
        :param QWidgetItem item: tree node widget item

        returns composite icon for this node, containing the icon box and perhaps other icons.
        """
        dd = self.declutter_data
        iconVal = v.computeIcon()
        iconName = f'box{iconVal:02d}.png'
        loaded_images = self.loaded_images

        # @+others
        # @+node:vitalije.20200329153544.1: *6* sorted_icons
        def sorted_icons(v: VNode) -> list[str]:
            """
            Returns a list of icon filenames for this node.
            The list is sorted to owner the 'where' key of image dicts.
            """
            icons = c.editCommands.getIconList(v)
            a = [x['file'] for x in icons if x['where'] == 'beforeIcon']
            a.append(iconName)
            a.extend(x['file'] for x in icons if x['where'] == 'beforeHeadline')
            return a

        # @+node:ekr.20171122064635.1: *6* declutter_replace
        def declutter_replace(arg: str, cmd: Callable, pattern: str) -> tuple[Callable, str]:
            """
            Executes cmd if cmd is any replace command and returns
            pair (commander, s), where 'commander' corresponds
            to the executed replacement operation, 's' is the substituted string.
            If cmd is not a replacement command returns (None, None)
            """
            # 's' is string when 'cmd' is recognized and is None otherwise
            s: str = None
            if cmd == 'REPLACE':
                try:
                    s = pattern.sub(arg, text)
                except re.error as e:
                    g.app.log(
                        f'Error in declutter: {e!r}"\n  REPLACE:{arg!r}\n  HEADLINE:{text!r}',
                        color='error',
                    )
            elif cmd == 'REPLACE-HEAD':
                s = text[: m.start()].rstrip()
            elif cmd == 'REPLACE-TAIL':
                s = text[m.end() :].lstrip()
            elif cmd == 'REPLACE-REST':
                s = (text[: m.start()] + text[m.end() :]).strip()

            if isinstance(s, str):
                # Save the operation

                def string_replacement(item, s):
                    return item.setText(0, s)

                # ... and apply it
                string_replacement(item, s)
                return string_replacement, s

            return None, s

        # @+node:ekr.20171122055719.1: *6* declutter_style
        def declutter_style(arg: str, cmd: Callable) -> tuple[Callable, str]:
            """
            Handles style options and returns pair '(commander, param)',
            where 'commander' is the applied style-modifying operation,
            param - the saved argument of that operation.
            Returns (None, param) if 'cmd' is not a style option.
            """
            param = c.styleSheetManager.expand_css_constants(arg).split()[0]
            modifier: Callable = None

            if cmd == 'ICON':

                def icon_modifier(item: QTreeWidgetItem, param: str) -> None:
                    # Does not fit well this function. And we cannot
                    # wrap list 'new_icons' in a saved argument as
                    # the list is recreated before each call.
                    new_icons.append(param)

                modifier = icon_modifier

            elif cmd == 'DOCICON':
                param = g.os_path_join(g.os_path_dirname(c.fileName()), param)

                def modifier(item: QTreeWidgetItem, param: str) -> None:
                    # As above, but for document relative icons
                    new_icons.append(param)
            elif cmd == 'BG':

                def modifier(item: QTreeWidgetItem, param: str) -> None:
                    item.setBackground(0, QtGui.QBrush(QtGui.QColor(param)))
            elif cmd == 'FG':

                def modifier(item: QTreeWidgetItem, param: str) -> None:
                    item.setForeground(0, QtGui.QBrush(QtGui.QColor(param)))
            elif cmd == 'FONT':

                def font_modifier(item: QTreeWidgetItem, param: str) -> None:
                    item.setFont(0, QtGui.QFont(param))

                modifier = font_modifier

            elif cmd == 'ITALIC':

                def italic_modifier(item: QTreeWidgetItem, param: str) -> None:
                    font = item.font(0)
                    font.setItalic(bool(int(param)))
                    item.setFont(0, font)

                modifier = italic_modifier

            elif cmd == 'WEIGHT':

                def weight_modifier(item: QTreeWidgetItem, param: str) -> None:
                    arg = getattr(QtGui.QFont.Weight, param, QtGui.QFont.Weight.Medium)
                    font = item.font(0)
                    font.setWeight(arg)
                    item.setFont(0, font)

                modifier = weight_modifier

            elif cmd == 'PX':

                def px_modifier(item: QTreeWidgetItem, param: str) -> None:
                    font = item.font(0)
                    font.setPixelSize(int(param))
                    item.setFont(0, font)

                modifier = px_modifier

            elif cmd == 'PT':

                def pt_modifier(item: QTreeWidgetItem, param: str) -> None:
                    font = item.font(0)
                    font.setPointSize(int(param))
                    item.setFont(0, font)

                modifier = pt_modifier

            # Apply the style update
            if modifier:
                modifier(item, param)
            return modifier, param

        # @+node:vitalije.20200327163522.1: *6* apply_declutter_rules
        def apply_declutter_rules(
            cmds: list[tuple[Callable, str]], pattern: str
        ) -> list[tuple[Any, str]]:
            """
            Applies all commands for the matched rule. Returns the list
            of the applied operations paired with their single parameter.
            """
            modifiers = []
            for cmd, arg in cmds:
                modifier, param = declutter_replace(arg, cmd, pattern)
                if not modifier:
                    modifier, param = declutter_style(arg, cmd)
                if modifier:
                    modifiers.append((modifier, param))
            return modifiers

        # @+node:vitalije.20200329162015.1: *6* preload_images
        def preload_images() -> None:
            for f in new_icons:
                if f not in loaded_images:
                    loaded_images[f] = g.app.gui.getImageImage(f)

        # @-others
        if (v.h, iconVal) in dd:
            # Apply saved adjustments to the text and to the _style_ of the node.
            new_icons, modifiers_and_args = dd[(v.h, iconVal)]
            for modifier, arg in modifiers_and_args:
                modifier(item, arg)
            new_icons = sorted_icons(v) + new_icons
        else:
            text = v.h
            new_icons = []
            modifiers_and_args = []
            for pattern, cmds in self.get_declutter_patterns():
                if m := pattern.match(text) or pattern.search(text):
                    modifiers_and_args.extend(apply_declutter_rules(cmds, pattern))
            # Save the lists of the icons and the adjusting operations.
            dd[(v.h, iconVal)] = new_icons, modifiers_and_args
            new_icons = sorted_icons(v) + new_icons
            preload_images()
        self.nodeIconsDict[v.gnx] = new_icons
        h = ':'.join(new_icons)
        icon = g.app.gui.iconimages.get(h)
        if not icon:
            preload_images()
            images = [loaded_images.get(x) for x in new_icons]
            icon = self.make_composite_icon(images)
            g.app.gui.iconimages[h] = icon
        # There is always at least a box icon.
        return icon

    # @+node:ekr.20110605121601.17874: *5* LeoQtTree.drawChildren
    def drawChildren(self, p: Position, parent_item: QTreeWidgetItem) -> None:
        """Draw the children of p if they should be expanded."""
        if not p:
            g.trace('can not happen: no p')
            return
        if p.hasChildren():
            if p.isExpanded():
                self.expandItem(parent_item)
                # Draw the tree recursively.
                for child in p.children():
                    self.drawTree(child, parent_item)
            else:
                # Draw only the hidden *direct* children.
                for child in p.children():
                    self.drawNode(child, parent_item)
                self.contractItem(parent_item)
        else:
            self.contractItem(parent_item)

    # @+node:ekr.20110605121601.17875: *5* LeoQtTree.drawNode
    def drawNode(self, p: Position, parent_item: QTreeWidgetItem) -> QTreeWidgetItem:
        """Draw the node p."""
        c = self.c
        v = p.v
        # Allocate the QTreeWidgetItem.
        item = self.createTreeItem(p, parent_item)
        # Update the data structures.
        itemHash = self.itemHash(item)
        self.position2itemDict[p.key()] = item
        self.item2positionDict[itemHash] = p.copy()  # was item
        self.item2vnodeDict[itemHash] = v  # was item
        d = self.vnode2itemsDict
        aList = d.get(v, [])
        if item not in aList:
            aList.append(item)
        d[v] = aList
        # Set the headline and maybe the icon.
        self.setItemText(item, p.h)
        # #1310: Add a tool tip.
        item.setToolTip(0, p.h)
        if self.use_declutter:
            icon = self.declutter_node(c, p.v, item)
            if icon:
                item.setIcon(0, icon)
            return item
        # Draw the icon: **Slow**, but allows per-vnode icons.
        icon = self.getCompositeIconImage(p.v)
        if icon:
            item.setIcon(0, icon)
        return item

    # @+node:ekr.20110605121601.17876: *5* LeoQtTree.drawTopTree
    def drawTopTree(self, p: Position) -> None:
        """Draw the tree rooted at p."""
        trace = 'drawing' in g.app.debug and not g.unitTesting
        if trace:
            t1 = time.process_time()
        c = self.c
        self.clear()
        # Draw all top-level nodes and their visible descendants.
        if c.hoistStack:
            bunch = c.hoistStack[-1]
            p = bunch.p
            h = p.h
            if len(c.hoistStack) == 1 and h.startswith('@chapter') and p.hasChildren():
                p = p.firstChild()
                while p:
                    self.drawTree(p)
                    p.moveToNext()
            else:
                self.drawTree(p)
        else:
            p = c.rootPosition()
            while p:
                self.drawTree(p)
                p.moveToNext()
        if trace:
            t2 = time.process_time()
            g.trace(f"{t2 - t1:5.2f} sec.", g.callers(3))

    # @+node:ekr.20110605121601.17877: *5* LeoQtTree.drawTree
    def drawTree(self, p: Position, parent_item: QTreeWidgetItem = None) -> None:
        if g.app.gui.isNullGui:
            return
        # Draw the (visible) parent node.
        item = self.drawNode(p, parent_item)
        # Draw all the visible children.
        self.drawChildren(p, parent_item=item)

    # @+node:vitalije.20200327162532.1: *5* LeoQtTree.get_declutter_patterns
    def get_declutter_patterns(self) -> list[tuple[Any, Any]]:
        "Initializes self.declutter_patterns from configuration and returns it"
        if self.declutter_patterns is not None:
            return self.declutter_patterns
        c = self.c
        patterns: list[Any] = []
        warned = False
        lines = c.config.getData("tree-declutter-patterns")
        for line in lines:
            try:
                cmd, arg = line.split(None, 1)
            except ValueError:
                # Allow empty arg, and guard against user errors.
                cmd = line.strip()
                arg = ''
            if cmd.startswith('#'):
                pass
            elif cmd == 'RULE':
                try:
                    patterns.append((re.compile(arg), []))
                except re.error:
                    g.app.log('Invalid declutter pattern: %r' % (arg,), color='error')
                    patterns.append((re.compile('a^'), []))  # create a rule that matches nothing
            else:
                if patterns:
                    patterns[-1][1].append((cmd, arg))
                elif not warned:
                    warned = True
                    g.app.log('Declutter patterns must start with RULE*', color='error')
        self.declutter_patterns = patterns
        return patterns

    # @+node:ekr.20110605121601.17878: *5* LeoQtTree.initData
    def initData(self) -> None:
        self.item2positionDict = {}
        self.item2vnodeDict = {}
        self.position2itemDict = {}
        self.vnode2itemsDict = {}
        self.editWidgetsDict = {}

    # @+node:ekr.20110605121601.17882: *4* LeoQtTree.redraw_after_head_changed
    def redraw_after_head_changed(self) -> None:
        """Redraw all Qt outline items cloned to c.p."""
        if self.busy:
            return
        c, p = self.c, self.c.p
        if p:
            h = p.h  # 2010/02/09: Fix bug 518823.
            for item in self.vnode2items(p.v):
                if self.isValidItem(item):
                    self.setItemText(item, h)
                    if self.use_declutter:  # #2844.
                        icon = self.declutter_node(c, p, item)
                        item.setIcon(0, icon)  # 0 is the column number.

    # @+node:ekr.20110605121601.17884: *4* LeoQtTree.redraw_after_select
    def redraw_after_select(self, p: Position = None) -> None:
        """Redraw the entire tree when an invisible node is selected."""
        if self.busy:
            if 'drawing' in g.app.debug:
                g.trace('busy!', g.callers(1))
            return
        self.full_redraw(p)
        # c.redraw_after_select calls tree.select indirectly.
        # Do not call it again here.

    # @+node:ekr.20140907201613.18986: *4* LeoQtTree.repaint (not used)
    def repaint(self) -> None:
        """Repaint the widget."""
        w = self.treeWidget
        w.repaint()
        w.resizeColumnToContents(0)  # 2009/12/22

    # @+node:ekr.20110605121601.17885: *3* LeoQtTree: Event handlers
    # @+node:ekr.20110605121601.17887: *4*  LeoQtTree: Click box event handlers
    # @+node:ekr.20110605121601.17888: *5* LeoQtTree.onClickBoxClick
    def onClickBoxClick(self, event: LeoKeyEvent, p: Position = None) -> None:
        if self.busy:
            return
        c = self.c
        g.doHook("boxclick1", c=c, p=p, event=event)
        g.doHook("boxclick2", c=c, p=p, event=event)
        c.outerUpdate()

    # @+node:ekr.20110605121601.17889: *5* LeoQtTree.onClickBoxRightClick
    def onClickBoxRightClick(self, event: LeoKeyEvent, p: Position = None) -> None:
        if self.busy:
            return
        c = self.c
        g.doHook("boxrclick1", c=c, p=p, event=event)
        g.doHook("boxrclick2", c=c, p=p, event=event)
        c.outerUpdate()

    # @+node:ekr.20110605121601.17890: *5* LeoQtTree.onPlusBoxRightClick
    def onPlusBoxRightClick(self, event: LeoKeyEvent, p: Position = None) -> None:
        if self.busy:
            return
        c = self.c
        g.doHook('rclick-popup', c=c, p=p, event=event, context_menu='plusbox')
        c.outerUpdate()

    # @+node:ekr.20110605121601.17891: *4*  LeoQtTree: Icon box event handlers
    # For Qt, there seems to be no way to trigger these events.
    # @+node:ekr.20110605121601.17892: *5* LeoQtTree.onIconBoxClick
    def onIconBoxClick(self, event: LeoKeyEvent, p: Position = None) -> None:
        if self.busy:
            return
        c = self.c
        g.doHook("iconclick1", c=c, p=p, event=event)
        g.doHook("iconclick2", c=c, p=p, event=event)
        c.outerUpdate()

    # @+node:ekr.20110605121601.17893: *5* LeoQtTree.onIconBoxRightClick
    def onIconBoxRightClick(self, event: LeoKeyEvent, p: Position = None) -> None:
        """Handle a right click in any outline widget."""
        if self.busy:
            return
        c = self.c
        g.doHook("iconrclick1", c=c, p=p, event=event)
        g.doHook("iconrclick2", c=c, p=p, event=event)
        c.outerUpdate()

    # @+node:ekr.20110605121601.17894: *5* LeoQtTree.onIconBoxDoubleClick
    def onIconBoxDoubleClick(self, event: LeoKeyEvent, p: Position = None) -> None:
        if self.busy:
            return
        c = self.c
        if not p:
            p = c.p
        if not g.doHook("icondclick1", c=c, p=p, event=event):
            self.endEditLabel()
            self.OnIconDoubleClick(p)  # Call the method in the base class.
        g.doHook("icondclick2", c=c, p=p, event=event)
        c.outerUpdate()

    # @+node:ekr.20110605121601.18437: *5* LeoQtTree.onContextMenu
    def onContextMenu(self, point: QPoint) -> None:
        """LeoQtTree: Callback for customContextMenuRequested events."""
        # #1286.
        c, w = self.c, self.treeWidget
        g.app.gui.onContextMenu(c, w, point)

    # @+node:ekr.20110605121601.17896: *5* LeoQtTree.onItemClicked
    def onItemClicked(self, item: QTreeWidgetItem, col: int) -> None:  # Col not used.
        """Handle a click in a BaseNativeTree widget item."""
        # This is called after an item is selected.
        if self.busy:
            return
        c = self.c
        try:
            self.busy = True
            p = self.item2position(item)
            if p:
                auto_edit = self.prev_v == p.v  # #1049.
                self.prev_v = p.v
                event = None
                #
                # Careful. We may have switched gui during unit testing.
                if hasattr(g.app.gui, 'qtApp'):
                    mods = g.app.gui.qtApp.keyboardModifiers()
                    isCtrl = bool(mods & KeyboardModifier.ControlModifier)
                    # We could also add support for Qt.ShiftModifier, Qt.AltModifier
                    # & Qt.MetaModifier.
                    if isCtrl:
                        if g.doHook("iconctrlclick1", c=c, p=p, event=event) is None:
                            c.frame.tree.OnIconCtrlClick(p)  # Call the base class method.
                        g.doHook("iconctrlclick2", c=c, p=p, event=event)
                    else:
                        # 2014/02/21: generate headclick1/2 instead of iconclick1/2
                        g.doHook("headclick1", c=c, p=p, event=event)
                        g.doHook("headclick2", c=c, p=p, event=event)
            else:
                auto_edit = None
                g.trace('*** no p')
            # 2011/05/27: click here is like ctrl-g.
            c.k.keyboardQuit(setFocus=False)
            c.treeWantsFocus()  # 2011/05/08: Focus must stay in the tree!
            c.outerUpdate()
            # 2011/06/01: A second *single* click on a selected node
            # enters editing state.
            if auto_edit and self.auto_edit:
                self.createTreeEditorForItem(item)
        finally:
            self.busy = False

    # @+node:ekr.20110605121601.17895: *5* LeoQtTree.onItemCollapsed
    def onItemCollapsed(self, item: QTreeWidgetItem) -> None:
        """Handle Qt tree-collapsed events."""
        if self.busy:
            return
        if 'drawing' in g.app.debug:
            g.trace(g.callers(1))
        c = self.c
        p = self.item2position(item)
        if not p:
            self.error('no p')
            return
        # Do **not** set lockouts here.
        # Only methods that actually generate events should set lockouts.
        self.select(p)
        if p.isExpanded():
            p.contract()
        c.redraw()

    # @+node:ekr.20110605121601.17897: *5* LeoQtTree.onItemDoubleClicked
    def onItemDoubleClicked(self, item: QTreeWidgetItem, col: int) -> None:  # col not used.
        """Handle a double click in a BaseNativeTree widget item."""
        if self.busy:  # Required.
            return
        c = self.c
        try:
            self.busy = True
            self.createTreeEditorForItem(item)
            p = self.item2position(item)
        # 2011/07/28: End the lockout here, not at the end.
        finally:
            self.busy = False
        if not p:
            self.error('no p')
            return
        # 2014/02/21: generate headdlick1/2 instead of icondclick1/2.
        if g.doHook("headdclick1", c=c, p=p, event=None) is None:
            c.frame.tree.OnIconDoubleClick(p)  # Call the base class method.
        g.doHook("headclick2", c=c, p=p, event=None)
        c.outerUpdate()

    # @+node:tom.20230324155453.1: *5* LeoQtTree.onItemEntered
    def onItemEntered(self, item: QTreeWidgetItem, col: int):
        """Expand/Contract a node when mouse moves over it.

        <CTRL-hover> -- expand;
        <SHIFT-hover> -- contract.
        """
        if not self.use_mouse_expand_gestures:
            return

        if hasattr(g.app.gui, 'qtApp'):
            mods = g.app.gui.qtApp.keyboardModifiers()
            isCtrl = bool(mods & KeyboardModifier.ControlModifier)
            isShift = bool(mods & KeyboardModifier.ShiftModifier)
            if isCtrl and not isShift:
                self.expandItem(item)
            elif isShift and not isCtrl:
                self.contractItem(item)

    # @+node:ekr.20110605121601.17898: *5* LeoQtTree.onItemExpanded
    def onItemExpanded(self, item: QTreeWidgetItem) -> None:
        """Handle Qt tree-expansion events."""
        if self.busy:  # Required
            return
        if 'drawing' in g.app.debug:
            g.trace(g.callers(1))
        c = self.c
        p = self.item2position(item)
        if not p:
            self.error('no p')
            return
        # Do **not** set lockouts here.
        # Only methods that actually generate events should set lockouts.
        self.select(p)
        if not p.isExpanded():
            p.expand()
        c.redraw()

    # @+node:ekr.20110605121601.17899: *5* LeoQtTree.onTreeSelect
    def onTreeSelect(self) -> None:
        """Select the proper position when a tree node is selected."""
        if self.busy:  # Required
            return
        c = self.c
        item = self.getCurrentItem()
        p = self.item2position(item)
        if not p:
            self.error(f"no p for item: {item}")
            return
        # Do **not** set lockouts here.
        # Only methods that actually generate events should set lockouts.
        self.select(p)  # This is a call to LeoTree.select(!!)
        c.outerUpdate()

    # @+node:ekr.20110605121601.17944: *3* LeoQtTree: Focus
    def getFocus(self) -> QWidget:
        return g.app.gui.get_focus(self.c)  # Bug fix: 2009/6/30

    findFocus = getFocus

    def setFocus(self) -> None:
        g.app.gui.set_focus(self.c, self.treeWidget)

    # @+node:ekr.20110605121601.18409: *3* LeoQtTree: Icons
    # @+node:ekr.20110605121601.18411: *4* LeoQtTree.getIcon & helpers
    def getIcon(self, v: VNode) -> QIcon:
        """Return the proper icon for position p."""
        if self.use_declutter:
            items = self.vnode2items(v)
            if items:
                return self.declutter_node(self.c, v, items[0])
        return self.getCompositeIconImage(v)

    # @+node:vitalije.20200329153148.1: *5* LeoQtTree.icon_filenames_for_node
    def icon_filenames_for_node(self, v: VNode) -> list[str]:
        """Returns a list of icon filenames for v."""
        nicon = f'box{v.iconVal:02d}.png'
        fnames = self.nodeIconsDict.get(v.gnx)
        if not fnames:
            icons = self.c.editCommands.getIconList(v)
            fnames = [x['file'] for x in icons if x['where'] == 'beforeIcon']
            fnames.append(nicon)
            fnames.extend(x['file'] for x in icons if x['where'] == 'beforeHeadline')
            self.nodeIconsDict[v.gnx] = fnames
        pat = re.compile(r'^box\d\d\.png$')
        loaded_images = self.loaded_images
        for i, f in enumerate(fnames):
            if pat.match(f):
                fnames[i] = nicon
                self.nodeIconsDict[v.gnx] = fnames
                f = nicon
            if f not in loaded_images:
                loaded_images[f] = g.app.gui.getImageImage(f)
        return fnames

    # @+node:vitalije.20200329153154.1: *5* LeoQtTree.make_composite_icon
    def make_composite_icon(self, images: list[Any]) -> QIcon:
        hsep = self.c.config.getInt('tree-icon-separation') or 0
        images = [x for x in images if x]
        height = max([i.height() for i in images])
        images = [i.scaledToHeight(height) for i in images]
        width = sum([i.width() for i in images]) + hsep * (len(images) - 1)
        pix = QtGui.QImage(width, height, Format.Format_ARGB32_Premultiplied)
        pix.fill(QtGui.QColor(0, 0, 0, 0))
        painter = QtGui.QPainter()
        ok = painter.begin(pix)
        x = 0
        for i in images:
            painter.drawPixmap(x, 0, i)
            x += i.width() + hsep
        if ok:
            painter.end()
        return QtGui.QIcon(QtGui.QPixmap.fromImage(pix))

    # @+node:ekr.20110605121601.18412: *5* LeoQtTree.getCompositeIconImage
    def getCompositeIconImage(self, v: VNode) -> QIcon:
        """Get the icon at v."""
        v.iconVal = v.computeIcon()
        fnames = self.icon_filenames_for_node(v)
        h = ':'.join(fnames)
        icon = g.app.gui.iconimages.get(h)
        loaded_images = self.loaded_images
        images = list(map(loaded_images.get, fnames))
        if not icon:
            icon = self.make_composite_icon(images)
            g.app.gui.iconimages[h] = icon
        return icon

    # @+node:ekr.20110605121601.17950: *4* LeoQtTree.setItemIcon
    def setItemIcon(self, item: QTreeWidgetItem, icon: QIcon) -> None:
        valid = item and self.isValidItem(item)
        if icon and valid:
            # Important: do not set lockouts here.
            # This will generate changed events,
            # but there is no itemChanged event handler.
            item.setIcon(0, icon)

    # @+node:ekr.20110605121601.18414: *3* LeoQtTree: Items
    # @+node:ekr.20110605121601.17943: *4*  LeoQtTree.item dict getters
    def itemHash(self, item: QTreeWidgetItem) -> str:
        return f"{repr(item)} at {str(id(item))}"

    def item2position(self, item: QTreeWidgetItem) -> Position:
        itemHash = self.itemHash(item)
        p = self.item2positionDict.get(itemHash)  # was item
        return p

    def item2vnode(self, item: QTreeWidgetItem) -> VNode:
        itemHash = self.itemHash(item)
        return self.item2vnodeDict.get(itemHash)  # was item

    def position2item(self, p: Position) -> QTreeWidgetItem:
        item = self.position2itemDict.get(p.key())
        return item

    def vnode2items(self, v: VNode) -> list[QTreeWidgetItem]:
        return self.vnode2itemsDict.get(v, [])

    def isValidItem(self, item: QTreeWidgetItem) -> bool:
        itemHash = self.itemHash(item)
        return itemHash in self.item2vnodeDict  # was item.

    # @+node:ekr.20110605121601.18415: *4* LeoQtTree.childIndexOfItem
    def childIndexOfItem(self, item: QTreeWidgetItem) -> int:
        parent = item and item.parent()
        if parent:
            n = parent.indexOfChild(item)
        else:
            w = self.treeWidget
            n = w.indexOfTopLevelItem(item)
        return n

    # @+node:ekr.20110605121601.18416: *4* LeoQtTree.childItems
    def childItems(self, parent_item: QTreeWidgetItem) -> list[QTreeWidgetItem]:
        """
        Return the list of child items of the parent item,
        or the top-level items if parent_item is None.
        """
        if parent_item:
            n = parent_item.childCount()
            items = [parent_item.child(z) for z in range(n)]
        else:
            w = self.treeWidget
            n = w.topLevelItemCount()
            items = [w.topLevelItem(z) for z in range(n)]
        return items

    # @+node:ekr.20110605121601.18418: *4* LeoQtTree.connectEditorWidget & callback
    def connectEditorWidget(self, w: QLineEdit, item: QTreeWidgetItem) -> QHeadlineWrapper:
        """
        Connect QLineEdit w to QTreeItem item.

        Also callback for when the editor ends.

        New in Leo 6.4: The callback handles all updates w/o calling onHeadChanged.
        """
        c, p, u = self.c, self.c.p, self.c.undoer

        # @+others  # define the callback.
        # @+node:ekr.20201109043641.1: *5* function: editingFinished_callback
        def editingFinished_callback() -> None:
            """Called when Qt emits the editingFinished signal."""
            s = w.text()
            # #3633: Replace newlines with a blank.
            if '\n' in s:
                s = re.sub(r'\s*\n\s*', ' ', s).replace('  ', ' ').rstrip()
            # #1310: update the tooltip.
            if p.h != s:
                # Update p.h and handle undo.
                item.setToolTip(0, s)
                undoData = u.beforeChangeHeadline(p)
                p.v.setHeadString(s)  # Set v.h *after* calling the undoer's before method.
                if not c.changed:
                    c.setChanged()
                # We must recolor the body because
                # the headline may contain directives.
                c.recolor(p)
                p.setDirty()
                u.afterChangeHeadline(p, 'Edit Headline', undoData)
            self.redraw_after_head_changed()
            c.outerUpdate()

        # @-others
        if w:
            # Hook up the widget.
            wrapper = self.getWrapper(w, item)
            w.editingFinished.connect(editingFinished_callback)
            return wrapper  # 2011/02/12
        return None

    # @+node:ekr.20110605121601.18419: *4* LeoQtTree.contractItem & expandItem
    def contractItem(self, item: QTreeWidgetItem) -> None:
        self.treeWidget.collapseItem(item)

    def expandItem(self, item: QTreeWidgetItem) -> None:
        self.treeWidget.expandItem(item)

    # @+node:ekr.20110605121601.18420: *4* LeoQtTree.createTreeEditorForItem
    def createTreeEditorForItem(self, item: QTreeWidgetItem) -> None:
        c = self.c
        w = self.treeWidget
        w.setCurrentItem(item)  # Must do this first.
        if self.use_declutter:
            item.setText(0, item._real_text)
        w.editItem(item)
        e = w.itemWidget(item, 0)  # e is a QLineEdit
        if e:
            e.setObjectName('headline')
        # Always do these!
        self.connectEditorWidget(e, item)
        self.sizeTreeEditor(c, e)

    # @+node:ekr.20110605121601.18421: *4* LeoQtTree.createTreeItem
    def createTreeItem(self, p: Position, parent_item: QTreeWidgetItem) -> QTreeWidgetItem:
        w = self.treeWidget
        itemOrTree = parent_item or w
        item = QtWidgets.QTreeWidgetItem(itemOrTree)
        item.setFlags(item.flags() | ItemFlag.ItemIsEditable)
        ChildIndicatorPolicy = QtWidgets.QTreeWidgetItem.ChildIndicatorPolicy
        item.setChildIndicatorPolicy(ChildIndicatorPolicy.DontShowIndicatorWhenChildless)
        try:
            g.visit_tree_item(self.c, p, item)
        except leoPlugins.TryNext:
            pass
        except AttributeError:
            pass  # g.unitTesting.
        return item

    # @+node:ekr.20110605121601.18423: *4* LeoQtTree.getCurrentItem
    def getCurrentItem(self) -> QTreeWidgetItem:
        w = self.treeWidget
        return w.currentItem()

    # @+node:ekr.20110605121601.18424: *4* LeoQtTree.getItemText
    def getItemText(self, item: QTreeWidgetItem) -> str:
        """Return the text of the item."""
        return item.text(0) if item else '<no item>'

    # @+node:ekr.20110605121601.18425: *4* LeoQtTree.getParentItem
    def getParentItem(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
        return item and item.parent()

    # @+node:ekr.20110605121601.18426: *4* LeoQtTree.getSelectedItems
    def getSelectedItems(self) -> list:
        w = self.treeWidget
        return w.selectedItems()

    # @+node:ekr.20110605121601.18427: *4* LeoQtTree.getTreeEditorForItem
    def getTreeEditorForItem(self, item: QTreeWidgetItem) -> QLineEdit:
        """Return the edit widget if it exists.
        Do *not* create one if it does not exist.
        """
        w = self.treeWidget
        e = w.itemWidget(item, 0)
        return e

    # @+node:ekr.20110605121601.18428: *4* LeoQtTree.getWrapper
    def getWrapper(self, e: QLineEdit, item: QTreeWidgetItem) -> QHeadlineWrapper:
        """Return the QHeadlineWrapper that wraps e (a QLineEdit)."""
        c = self.c
        if not e:
            return None
        if wrapper := self.editWidgetsDict.get(e):
            return wrapper
        if not item:
            return None
        wrapper = qt_text.QHeadlineWrapper(c, item, name='head', widget=e)
        self.editWidgetsDict[e] = wrapper
        return wrapper

    # @+node:ekr.20110605121601.18429: *4* LeoQtTree.nthChildItem
    def nthChildItem(self, n: int, parent_item: QTreeWidgetItem) -> QTreeWidgetItem:
        children = self.childItems(parent_item)
        if n < len(children):
            item = children[n]
        else:
            # This is **not* an error.
            # It simply means that we need to redraw the tree.
            item = None
        return item

    # @+node:ekr.20110605121601.18430: *4* LeoQtTree.scrollToItem
    def scrollToItem(self, item: QTreeWidgetItem) -> None:
        """
        Scroll the tree widget so that item is visible.
        Leo's core no longer calls this method.
        """
        w = self.treeWidget
        hPos, vPos = self.getScroll()
        # Fix #265: Erratic scrolling bug.
        # w.PositionAtCenter causes unwanted scrolling.
        w.scrollToItem(item, w.EnsureVisible)
        self.setHScroll(0)  # Necessary

    # @+node:ekr.20110605121601.18431: *4* LeoQtTree.setCurrentItemHelper
    def setCurrentItemHelper(self, item: QTreeWidgetItem) -> None:
        w = self.treeWidget
        w.setCurrentItem(item)

    # @+node:ekr.20110605121601.18432: *4* LeoQtTree.setItemText
    def setItemText(self, item: QTreeWidgetItem, s: str) -> None:
        if item:
            item.setText(0, s)
            if self.use_declutter:
                item._real_text = s

    # @+node:tbrown.20160406221505.1: *4* LeoQtTree.sizeTreeEditor
    @staticmethod
    def sizeTreeEditor(c: Cmdr, editor: QLineEdit) -> None:
        """Size a QLineEdit in a tree headline so scrolling occurs"""
        # space available in tree widget
        space = c.frame.tree.treeWidget.size().width()
        # left hand edge of editor within tree widget
        used = editor.geometry().x() + 4  # + 4 for edit cursor
        # limit width to available space
        editor.resize(space - used, editor.size().height())

    # @+node:ekr.20110605121601.18433: *3* LeoQtTree: Scroll bars
    # @+node:ekr.20110605121601.18434: *4* LeoQtTree.getSCroll
    def getScroll(self) -> tuple[int, int]:
        """Return the hPos,vPos for the tree's scrollbars."""
        w = self.treeWidget
        hScroll = w.horizontalScrollBar()
        vScroll = w.verticalScrollBar()
        hPos = hScroll.sliderPosition()
        vPos = vScroll.sliderPosition()
        return hPos, vPos

    # @+node:btheado.20111110215920.7164: *4* LeoQtTree.scrollDelegate
    def scrollDelegate(self, kind: str) -> None:
        """
        Scroll a QTreeWidget up or down or right or left.
        kind is in ('down-line','down-page','up-line','up-page', 'right', 'left')
        """
        c = self.c
        w = self.treeWidget
        if kind in ('left', 'right'):
            hScroll = w.horizontalScrollBar()
            if kind == 'right':
                delta = hScroll.pageStep()
            else:
                delta = -hScroll.pageStep()
            hScroll.setValue(int(hScroll.value() + delta))
        else:
            vScroll = w.verticalScrollBar()
            h = w.size().height()
            lineSpacing = w.fontMetrics().lineSpacing()
            n = h / lineSpacing
            if kind == 'down-half-page':
                delta = n / 2
            elif kind == 'down-line':
                delta = 1
            elif kind == 'down-page':
                delta = n
            elif kind == 'up-half-page':
                delta = -n / 2
            elif kind == 'up-line':
                delta = -1
            elif kind == 'up-page':
                delta = -n
            else:
                delta = 0
                g.trace('bad kind:', kind)
            val = vScroll.value()
            vScroll.setValue(int(val + delta))
        c.treeWantsFocus()

    # @+node:ekr.20110605121601.18435: *4* LeoQtTree.setH/VScroll
    def setHScroll(self, hPos: int) -> None:
        w = self.treeWidget
        hScroll = w.horizontalScrollBar()
        hScroll.setValue(hPos)

    def setVScroll(self, vPos: int) -> None:
        w = self.treeWidget
        vScroll = w.verticalScrollBar()
        vScroll.setValue(vPos)

    # @+node:ekr.20110605121601.17905: *3* LeoQtTree: Selecting & editing
    # @+node:ekr.20110605121601.17908: *4* LeoQtTree.headline_wrapper
    def headline_wrapper(self, p: Position) -> QHeadlineWrapper:
        """Returns the edit widget (A QLineEdit) for position p."""
        item = self.position2item(p)
        if item:
            e = self.getTreeEditorForItem(item)
            if e:
                # Create a wrapper widget for Leo's core.
                w = self.getWrapper(e, item)
                return w
            # This is not an error
            # But warning: calling this method twice might not work!
            return None
        return None

    edit_widget = headline_wrapper  # Compatibility.

    # @+node:ekr.20110605121601.17909: *4* LeoQtTree.editLabel and helper
    def editLabel(
        self,
        p: Position,
        selectAll: bool = False,
        selection: tuple = None,
    ) -> tuple[QLineEdit, QHeadlineWrapper]:
        """Start editing p's headline."""
        if self.busy:
            return None
        c = self.c
        # Do any scheduled redraw.
        # This won't do anything in the new redraw scheme.
        c.outerUpdate()
        item = self.position2item(p)
        if item:
            if self.use_declutter:
                item.setText(0, item._real_text)
            e, wrapper = self.editLabelHelper(item, selectAll, selection)
        else:
            e, wrapper = None, None
            self.error(f"no item for {p}")
        if e:
            self.sizeTreeEditor(c, e)
            # A nice hack: just set the focus request.
            c.requestedFocusWidget = e
        return e, wrapper

    # @+node:ekr.20110605121601.18422: *5* LeoQtTree.editLabelHelper
    def editLabelHelper(
        self,
        item: QTreeWidgetItem,
        selectAll: bool = False,
        selection: tuple = None,
    ) -> tuple[QLineEdit, QHeadlineWrapper]:
        """Helper for qtree.editLabel."""
        c, vc = self.c, self.c.vimCommands
        w = self.treeWidget
        # Must do this first.
        # This generates a call to onTreeSelect.
        w.setCurrentItem(item)
        w.editItem(item)  # Generates focus-in event that tree doesn't report.
        e = w.itemWidget(item, 0)  # A QLineEdit.
        s = e.text()
        if s == 'newHeadline':
            selectAll = True
        start: int
        n: int
        if selection:
            # pylint: disable=unpacking-non-sequence
            # Fix bug https://groups.google.com/d/msg/leo-editor/RAzVPihqmkI/-tgTQw0-LtwJ
            # Note: negative lengths are allowed.
            i, j, ins = selection
            if ins is None:
                start, n = i, abs(i - j)
                # This case doesn't happen for searches.
            elif ins == j:
                start, n = i, j - i
            else:
                start, n = j, i - j
        elif selectAll:
            start, n, ins = 0, len(s), len(s)
        else:
            start, n, ins = len(s), 0, len(s)
        e.setObjectName('headline')
        e.setSelection(start, n)
        # e.setCursorPosition(ins) # Does not work.
        e.setFocus()
        wrapper = self.connectEditorWidget(e, item)  # Hook up the widget.
        if vc and c.vim_mode:  # and selectAll
            # For now, *always* enter insert mode.
            if vc.is_text_wrapper(wrapper):
                vc.begin_insert_mode(w=wrapper)
            else:
                g.trace('not a text widget!', wrapper)
        return e, wrapper

    # @+node:ekr.20110605121601.17911: *4* LeoQtTree.endEditLabel
    def endEditLabel(self) -> None:
        """
        Override LeoTree.endEditLabel.

        Just end editing of the presently-selected QLineEdit!
        This will trigger the editingFinished_callback defined in createEditorForItem.
        """
        item = self.getCurrentItem()
        if not item:
            return
        e = self.getTreeEditorForItem(item)
        if not e:
            return
        # Trigger the end-editing event.
        w = self.treeWidget
        w.closeEditor(e, EndEditHint.NoHint)
        w.setCurrentItem(item)

    # @+node:ekr.20110605121601.17915: *4* LeoQtTree.getSelectedPositions
    def getSelectedPositions(self) -> list[Position]:
        return [self.item2position(z) for z in self.getSelectedItems()]

    # @+node:ekr.20110605121601.17914: *4* LeoQtTree.setHeadline
    def setHeadline(self, p: Position, s: str) -> None:
        """Force the actual text of the headline widget to p.h."""
        # This is used by unit tests to force the headline and p into alignment.
        if not p:
            return
        # Don't do this here: the caller should do it.
        # p.setHeadString(s)
        w = self.headline_wrapper(p)
        if w:
            w.setAllText(s)
        else:
            item = self.position2item(p)
            if item:
                self.setItemText(item, s)

    # @+node:ekr.20110605121601.17913: *4* LeoQtTree.setItemForCurrentPosition
    def setItemForCurrentPosition(self) -> QTreeWidgetItem:
        """Select the item for c.p"""
        p = self.c.p
        if self.busy:
            return None
        if not p:
            return None
        item = self.position2item(p)
        if not item:
            # This is not necessarily an error.
            # We often attempt to select an item before redrawing it.
            return None
        item2 = self.getCurrentItem()
        if item == item2:
            return item
        try:
            self.busy = True
            # This generates gui events, so we must use a lockout.
            self.treeWidget.setCurrentItem(item)
        finally:
            self.busy = False
        return item

    # @+node:ekr.20190613080606.1: *4* LeoQtTree.unselectItem
    def unselectItem(self, p: Position) -> None:
        item = self.position2item(p)
        if item:
            item.setSelected(False)

    # @-others


# @-others
# @@language python
# @@tabwidth -4
# @@pagewidth 80
# @-leo
