# @+leo-ver=5-thin
# @+node:ekr.20031218072017.2810: * @file leoCommands.py
# @+<< leoCommands imports >>
# @+node:ekr.20040712045933: ** << leoCommands imports >>
from __future__ import annotations
from collections.abc import Callable, Generator, Iterable, Sequence
import glob
import json
import os
import re
import subprocess
from shutil import which
import sys
import tabnanny
import tempfile
import time
import tokenize
from typing import cast, Any, TYPE_CHECKING
from xml.etree import ElementTree

from leo.core import leoGlobals as g

# The leoCommands ctor now does most leo.core.leo* imports,
# thereby breaking circular dependencies.
from leo.core.leoNodes import Position, VNode

# @-<< leoCommands imports >>
# @+<< leoCommands annotations >>
# @+node:ekr.20220820051212.1: ** << leoCommands annotations >>
if TYPE_CHECKING:  # pragma: no cover
    from leo.core.leoApp import PreviousSettings
    from leo.core.leoGui import LeoKeyEvent

    # The following imports *are* required, but flake8 won't know that without the asserts.

    # 11 subcommanders...

    from leo.core.leoAtFile import AtFile

    assert AtFile is not None

    from leo.core.leoChapters import ChapterController

    assert ChapterController is not None

    from leo.core.leoFileCommands import FileCommands

    assert FileCommands is not None

    from leo.core.leoFind import LeoFind

    assert LeoFind is not None

    from leo.core.leoImport import LeoImportCommands

    assert LeoImportCommands is not None

    from leo.core.leoKeys import KeyHandlerClass

    assert KeyHandlerClass is not None

    from leo.core.leoHistory import NodeHistory

    assert NodeHistory is not None

    from leo.core.leoPersistence import PersistenceDataController

    assert PersistenceDataController is not None

    from leo.core.leoPrinting import PrintingController

    assert PrintingController is not None

    from leo.core.leoShadow import ShadowController

    assert ShadowController is not None

    from leo.core.leoUndo import Undoer

    assert Undoer is not None

    from leo.core.leoVim import VimCommands

    assert VimCommands is not None

    # 14 command handlers...
    from leo.commands.abbrevCommands import AbbrevCommandsClass

    assert AbbrevCommandsClass is not None

    from leo.commands.bufferCommands import BufferCommandsClass

    assert BufferCommandsClass is not None

    from leo.commands.controlCommands import ControlCommandsClass

    assert ControlCommandsClass is not None

    from leo.commands.convertCommands import ConvertCommandsClass

    assert ConvertCommandsClass is not None

    from leo.commands.debugCommands import DebugCommandsClass

    assert DebugCommandsClass is not None

    from leo.commands.editCommands import EditCommandsClass

    assert EditCommandsClass is not None

    from leo.commands.editFileCommands import EditFileCommandsClass

    assert EditFileCommandsClass is not None

    from leo.commands.gotoCommands import GoToCommands

    assert GoToCommands is not None

    from leo.commands.helpCommands import HelpCommandsClass

    assert HelpCommandsClass is not None

    from leo.commands.keyCommands import KeyHandlerCommandsClass

    assert KeyHandlerCommandsClass is not None

    from leo.commands.killBufferCommands import KillBufferCommandsClass

    assert KillBufferCommandsClass is not None

    from leo.commands.rectangleCommands import RectangleCommandsClass

    assert RectangleCommandsClass is not None

    from leo.core.leoRst import RstCommands

    assert RstCommands is not None

    from leo.commands.spellCommands import SpellCommandsClass

    assert SpellCommandsClass is not None

    # Other objects...
    from leo.core.leoGui import LeoGui
    from leo.core.qt_frame import LeoQtMenu
    from leo.plugins.qt_gui import StyleSheetManager
    from leo.plugins.qt_text import QTextMixin

    Args = Any
    KWargs = Any
    RegexFlag = int | re.RegexFlag  # re.RegexFlag does not define 0
    Value = Any
    Widget = Any  # 'Any' is the correct annotation for base class widgets.

    PositionGenerator = Generator[Position, None, None]
    VNodeGenerator = Generator[VNode, None, None]

# @-<< leoCommands annotations >>


def cmd(name: str) -> Callable:
    """Command decorator for the Commands class."""
    return g.new_cmd_decorator(name, ['c'])


# @+others
# @+node:ekr.20160514120615.1: ** class Commands
class Commands:
    # @+<< docstring: Commands class >>
    # @+node:ekr.20240406100300.1: *3* << docstring: Commands class >>
    """
    A per-outline class that implements most of Leo's commands. The
    "c" predefined object is an instance of this class.

    c.initObjects() creates subcommanders corresponding to files in the
    leo/core and leo/commands. All of Leo's core code is accessible
    via this class and its subcommanders.

    g.app.pluginsController is Leo's plugins controller. Many plugins
    inject controllers objects into the Commands class. These are
    another kind of subcommander.

    The @g..commander_command decorator injects methods into this class.
    """

    # @-<< docstring: Commands class >>

    # @+others
    # @+node:ekr.20031218072017.2811: *3*  c.Birth & death
    # @+node:ekr.20031218072017.2812: *4* c.__init__ & helpers
    def __init__(
        self,
        fileName: str,
        gui: LeoGui | None = None,
        parentFrame: Any = None,
        previousSettings: PreviousSettings | None = None,
        relativeFileName: str = '',
    ) -> None:
        t1 = time.process_time()
        c = self

        # Official ivars.
        self._currentPosition = cast(Position, None)
        self._topPosition: Position | None = None
        self.command_count = 0
        self.frame: Any = None  # This saves a lot of asserts.
        self.parentFrame: Widget = parentFrame  # New in Leo 6.0.
        self.gui: LeoGui = gui or g.app.gui

        # The stylesheet manager does not exist in all guis.
        self.styleSheetManager: StyleSheetManager | None = None

        # The order of these calls does not matter.
        c.initCommandIvars()
        c.initDebugIvars()
        c.initDocumentIvars()
        c.initEventIvars()
        c.initFileIvars(fileName, relativeFileName)
        c.initOptionsIvars()
        # Instantiate c.config *before* initing objects.
        self.config = None
        c.initSettings(previousSettings)

        # Initialize all subsidiary objects, including subcommanders.
        c.initObjects(self.gui)
        assert c.frame
        assert c.frame.c

        # Complete the init!
        t2 = time.process_time()
        c.finishCreate()  # Slightly slow.
        t3 = time.process_time()
        if 'speed' in g.app.debug:
            print('c.__init__')
            print(
                f"    1: {t2 - t1:5.2f}\n"  # 0.00 sec.
                f"    2: {t3 - t2:5.2f}\n"  # 0.53 sec: c.finishCreate.
                f"total: {t3 - t1:5.2f}"
            )

    # @+node:ekr.20231123014221.1: *5* c.computeTabTitle
    def computeTabTitle(self) -> str:
        """
        Return the tab title for this commander.
        """
        c = self
        if file_name := c.fileName():
            return file_name
        # Return 'untitled' or 'untitled{n}
        n = g.app.numberOfUntitledWindows
        n_s = '' if n < 2 else str(n)
        title = f"untitled{n_s}"
        return title

    # @+node:ekr.20120217070122.10475: *5* c.computeWindowTitle
    def computeWindowTitle(self, fileName: str = '') -> str:
        """
        Return the title for the top-level window.
        """
        c = self
        file_name = fileName or c.fileName()
        if not file_name:
            return 'untitled'
        if re.match(r'^untitled\d+$', file_name):
            return file_name
        branch = g.gitBranchName(file_name)
        branch_s = f"{branch}: " if branch else ''
        # Pretty-print file_name.
        path, fn = g.os_path_split(file_name)
        name_s = f"{fn} in {path}" if path else fn
        # Regularize slashes.
        if os.sep in '/\\':
            name_s = name_s.replace('/', os.sep).replace('\\', os.sep)
        return f"{branch_s}{name_s}"

    # @+node:ekr.20120217070122.10473: *5* c.initCommandIvars
    def initCommandIvars(self) -> None:
        """Init ivars used while executing a command."""
        self.commandsDict: dict[str, Any] = {}  # Keys: command names, values: various.
        self.disableCommandsMessage = ''  # The presence of this message disables all commands.
        # One of three places that g.doHook looks for hook functions.
        self.hookFunction: Callable | None = None
        self.ignoreChangedPaths = False  # True: disable path changed message in at.WriteAllHelper.
        self.inCommand = False  # Interlocks to prevent premature closing of a window.
        self.outlineToNowebDefaultFileName: str = "noweb.nw"  # For Outline To Noweb dialog.
        # For hoist/dehoist commands.
        # Affects drawing routines and find commands, but *not* generators.
        self.hoistStack: list[g.Bunch] = []  # Stack of g.Bunches to be root of drawn tree.
        # For outline navigation.
        self.navPrefix: str = ''  # Must always be a string.
        self.navTime: float | None = None
        self.recent_commands_list: list[str] = []  # List of command names.

    # @+node:ekr.20120217070122.10466: *5* c.initDebugIvars
    def initDebugIvars(self) -> None:
        """Init Commander debugging ivars."""
        self.command_count = 0
        self.scanAtPathDirectivesCount = 0
        self.trace_focus_count = 0

    # @+node:ekr.20120217070122.10471: *5* c.initDocumentIvars
    def initDocumentIvars(self) -> None:
        """Init per-document ivars."""
        self.expansionLevel = 0  # The expansion level of this outline.
        self.expansionNode = None  # The last node we expanded or contracted.
        self.nodeConflictList: list[Position] = []  # List of nodes with conflicting read-time data.
        self.nodeConflictFileName: str = ''  # The fileName for c.nodeConflictList.
        # Non-persistent dictionary for free use by scripts and plugins.
        self.user_dict: dict[str, Value] = {}

    # @+node:ekr.20120217070122.10467: *5* c.initEventIvars
    def initEventIvars(self) -> None:
        """Init ivars relating to gui events."""
        self.configInited = False
        self.doubleClickFlag = False
        self.exists = True  # Indicate that this class exists and has not been destroyed.
        self.in_qt_dialog = False  # True: in a qt dialog.
        self.loading = False  # True: we are loading a file: disables c.setChanged()
        self.promptingForClose = False  # True: lock out additional closing dialogs.

        # Flags for c.outerUpdate...
        self.enableRedrawFlag = True
        self.requestCloseWindow = False
        self.requestedFocusWidget: Widget | None = None
        self.requestLaterRedraw = False

    # @+node:ekr.20120217070122.10472: *5* c.initFileIvars
    def initFileIvars(self, fileName: str, relativeFileName: str) -> None:
        """Init file-related ivars of the commander."""
        self.changed = False  # True: the outline has changed since the last save.
        self.ignored_at_file_nodes: list[str] = []  # List of headlines for c.raise_error_dialogs.
        self.last_dir: str = ''  # The last used directory.
        # Do _not_ use os_path_norm: it converts an empty path to '.' (!!)
        self.mFileName: str = fileName or ''
        self.mRelativeFileName = relativeFileName or ''
        # List of orphaned nodes for c.raise_error_dialogs.
        self.orphan_at_file_nodes: list[str] = []

    # @+node:ekr.20120217070122.10470: *5* c.initObjects
    def initObjects(self, gui: LeoGui) -> None:
        c = self

        # Create the hidden root VNode.
        self.hiddenRootNode = VNode(context=c, gnx='hidden-root-vnode-gnx')
        self.hiddenRootNode.h = '<hidden root vnode>'

        # Create the gui frame.
        title = c.computeTabTitle()
        if not g.app.initing:
            g.doHook("before-create-leo-frame", c=c)
        self.frame = gui.createLeoFrame(c, title)
        assert self.frame
        assert self.frame.c == c
        from leo.core import leoHistory

        self.nodeHistory = leoHistory.NodeHistory(c)
        self.initConfigSettings()
        c.setWindowPosition()  # Do this after initing settings.

        # Break circular import dependencies by doing imports here.
        # All these imports take almost 3/4 sec in the leoBridge.
        t1 = time.process_time()
        from leo.core import leoAtFile
        from leo.core import leoBeautify  # So decorators are executed.

        assert leoBeautify  # for pyflakes.
        from leo.core.leoCache import CommanderWrapper
        from leo.core import leoChapters

        # User commands...
        from leo.commands import abbrevCommands
        from leo.commands import bufferCommands
        from leo.commands import checkerCommands  # Required.

        assert checkerCommands  # To suppress a pyflakes warning.

        from leo.commands import controlCommands
        from leo.commands import convertCommands
        from leo.commands import debugCommands
        from leo.commands import editCommands
        from leo.commands import editFileCommands
        from leo.commands import gotoCommands
        from leo.commands import helpCommands
        from leo.commands import keyCommands
        from leo.commands import killBufferCommands
        from leo.commands import rectangleCommands
        from leo.commands import spellCommands

        # Import files to execute @g.commander_command decorators
        from leo.core import leoCompare

        assert leoCompare
        from leo.core import leoDebugger

        assert leoDebugger
        from leo.commands import commanderEditCommands

        assert commanderEditCommands
        from leo.commands import commanderFileCommands

        assert commanderFileCommands
        from leo.commands import commanderHelpCommands

        assert commanderHelpCommands
        from leo.commands import commanderOutlineCommands

        assert commanderOutlineCommands
        # Other subcommanders.
        from leo.core import leoFind  # Leo 4.11.1
        from leo.core import leoKeys
        from leo.core import leoFileCommands
        from leo.core import leoImport
        from leo.core import leoMarkup
        from leo.core import leoPersistence
        from leo.core import leoPrinting
        from leo.core import leoRst
        from leo.core import leoShadow
        from leo.core import leoUndo
        from leo.core import leoVim

        t2 = time.process_time()
        if 0:
            g.trace(f"Import time: {t2 - t1:.2f}")

        # fmt: off

        # Define 11 subcommanders.
        self.keyHandler = self.k    = leoKeys.KeyHandlerClass(c)
        self.chapterController      = leoChapters.ChapterController(c)
        self.shadowController       = leoShadow.ShadowController(c)
        self.fileCommands           = leoFileCommands.FileCommands(c)
        self.findCommands           = leoFind.LeoFind(c)
        self.atFileCommands         = leoAtFile.AtFile(c)
        self.importCommands         = leoImport.LeoImportCommands(c)
        self.markupCommands         = leoMarkup.MarkupCommands(c)
        self.persistenceController  = leoPersistence.PersistenceDataController(c)
        self.printingController     = leoPrinting.PrintingController(c)
        self.undoer                 = leoUndo.Undoer(c)

        # 15 command handlers...
        self.abbrevCommands     = abbrevCommands.AbbrevCommandsClass(c)
        self.bufferCommands     = bufferCommands.BufferCommandsClass(c)
        self.controlCommands    = controlCommands.ControlCommandsClass(c)
        self.convertCommands    = convertCommands.ConvertCommandsClass(c)
        self.debugCommands      = debugCommands.DebugCommandsClass(c)
        self.editCommands       = editCommands.EditCommandsClass(c)
        self.editFileCommands   = editFileCommands.EditFileCommandsClass(c)
        self.gotoCommands       = gotoCommands.GoToCommands(c)
        self.helpCommands       = helpCommands.HelpCommandsClass(c)
        self.keyHandlerCommands = keyCommands.KeyHandlerCommandsClass(c)
        self.killBufferCommands = killBufferCommands.KillBufferCommandsClass(c)
        self.rectangleCommands  = rectangleCommands.RectangleCommandsClass(c)
        self.rstCommands        = leoRst.RstCommands(c)
        self.spellCommands      = spellCommands.SpellCommandsClass(c)
        self.vimCommands        = leoVim.VimCommands(c)

        # fmt: on

        # Create the list of subcommanders.
        self.subCommanders = [
            self.abbrevCommands,
            self.atFileCommands,
            self.bufferCommands,
            self.chapterController,
            self.controlCommands,
            self.convertCommands,
            self.debugCommands,
            self.editCommands,
            self.editFileCommands,
            self.fileCommands,
            self.findCommands,
            self.gotoCommands,
            self.helpCommands,
            self.importCommands,
            self.keyHandler,
            self.keyHandlerCommands,
            self.killBufferCommands,
            self.persistenceController,
            self.printingController,
            self.rectangleCommands,
            self.rstCommands,
            self.shadowController,
            self.spellCommands,
            self.vimCommands,
            self.undoer,
        ]

        # Other objects
        # A list of other classes that have a reloadSettings method
        c.configurables = c.subCommanders[:]
        c.db = CommanderWrapper(c)
        c.free_layout = None  # Compatibility. Always None.
        c.quicksearch_controller = None  # Leo 6.8.0: Set by quicksearch plugin.

        if hasattr(g.app.gui, 'styleSheetManagerClass'):
            self.styleSheetManager = g.app.gui.styleSheetManagerClass(c)
            self.subCommanders.append(self.styleSheetManager)
        else:
            self.styleSheetManager = None

    # @+node:ekr.20120217070122.10469: *5* c.initOptionsIvars
    def initOptionsIvars(self) -> None:
        """Init Commander ivars corresponding to user options."""
        self.fixed = False
        self.fixedWindowPosition: list[int] = []
        self.forceExecuteEntireBody = False
        self.focus_border_color = 'white'
        self.focus_border_width = 1  # pixels
        self.outlineHasInitialFocus = False
        self.page_width = 132
        self.sparse_find = True
        self.sparse_move = True
        self.sparse_spell = True
        self.sparse_goto_visible = False
        self.stayInTreeAfterSelect = False
        self.tab_width = -4
        self.tangle_batch_flag = False
        self.target_language = "python"
        self.untangle_batch_flag = False
        self.vim_mode = False

    # @+node:ekr.20140815160132.18837: *5* c.initSettings
    def initSettings(self, previousSettings: PreviousSettings | None = None) -> None:
        """Instantiate c.config from previous settings."""
        c = self
        from leo.core import leoConfig

        c.config = leoConfig.LocalConfigManager(c, previousSettings)  # type:ignore

    # @+node:ekr.20031218072017.2814: *4* c.__repr__ & __str__
    def __repr__(self) -> str:
        return f"Commander {id(self)}: {repr(self.mFileName)}"

    __str__ = __repr__

    # @+node:ekr.20050920093543: *4* c.finishCreate & helpers
    def finishCreate(self) -> None:
        """
        Finish creating the commander and all sub-objects.
        This is the last step in the startup process.
        """
        c, k = self, self.k
        assert c.gui
        assert k
        t1 = time.process_time()
        c.frame.finishCreate()  # Slightly slow.
        t2 = time.process_time()
        c.miniBufferWidget = c.frame.miniBufferWidget  # Will be None for nullGui.
        # Only c.abbrevCommands needs a finishCreate method.
        c.abbrevCommands.finishCreate()
        # Finish other objects...
        c.createCommandNames()
        k.finishCreate()
        c.findCommands.finishCreate()
        if not c.gui.isNullGui and not g.unitTesting:
            # #2485: register idle_focus_helper in the proper context.
            assert g.app.pluginsController
            try:
                g.app.pluginsController.loadingModuleNameStack.append('leo.core.leoCommands')
                g.registerHandler('idle', c.idle_focus_helper)
            finally:
                g.app.pluginsController.loadingModuleNameStack.pop()
        if getattr(c.frame, 'menu', None):
            c.frame.menu.finishCreate()
        if getattr(c.frame, 'log', None):
            c.frame.log.finishCreate()
        c.undoer.clearUndoState()
        if c.vimCommands and c.vim_mode:
            c.vimCommands.finishCreate()  # Menus must exist at this point.
        # Do not call chapterController.finishCreate here:
        # It must be called after the first real redraw.
        g.check_cmd_instance_dict(c, g)
        c.bodyWantsFocus()
        t3 = time.process_time()
        if 'speed' in g.app.debug:
            print('c.finishCreate')
            print(
                f"    1: {t2 - t1:5.2f}\n"  # 0.20 sec: qtGui.finishCreate.
                f"    2: {t3 - t2:5.2f}\n"  # 0.16 sec: everything else.
                f"total: {t3 - t1:5.2f}"
            )

    # @+node:ekr.20140815160132.18835: *5* c.createCommandNames
    def createCommandNames(self) -> None:
        """
        Create all entries in c.commandsDict.
        Do *not* clear c.commandsDict here.
        """
        for commandName, func in g.global_commands_dict.items():
            self.k.registerCommand(commandName, func)

    # @+node:ekr.20051007143620: *5* c.printCommandsDict
    def printCommandsDict(self) -> None:
        c = self
        print('Commands...')
        for key in sorted(c.commandsDict):
            command = c.commandsDict.get(key)
            print(f"{key:30} = {command.__name__ if command else '<None>'}")
        print('')

    # @+node:ekr.20041130173135: *4* c.hash
    # This is a bad idea.

    def hash(self) -> str:  # Leo 6.6.2: Always return a string.
        c = self
        if c.mFileName:
            return g.finalize(c.mFileName).lower()
        return f"{id(self)!s}"

    # @+node:ekr.20110509064011.14563: *4* c.idle_focus_helper
    def idle_focus_helper(self, tag: str, keys: dict) -> None:
        """An idle-time handler that ensures that focus is *somewhere*."""
        trace = 'focus' in g.app.debug
        c = self
        assert tag == 'idle'
        if g.unitTesting:
            return
        if keys.get('c') != c:
            return
        if c.in_qt_dialog:
            return
        w = g.app.gui.get_focus(at_idle=True)
        if g.app.gui.active:
            from leo.plugins.qt_frame import QtTabBarWrapper

            if w and isinstance(w, QtTabBarWrapper):
                if trace:
                    w_class = w and w.__class__.__name__
                    g.trace('***** unusual focus', w_class)
                # Fix bug 270: Leo's keyboard events doesn't work after "Insert"
                # on headline and Alt+Tab, Alt+Tab
                # Presumably, intricate details of Qt event handling are involved.
                # The focus was in the tree, so put the focus back in the tree.
                c.treeWantsFocusNow()
            # elif not w and active:
            # c.bodyWantsFocusNow()

    # @+node:ekr.20081005065934.1: *4* c.initAfterLoad
    def initAfterLoad(self) -> None:
        """Provide an official hook for late inits of the commander."""

    # @+node:ekr.20090213065933.6: *4* c.initConfigSettings
    def initConfigSettings(self) -> None:
        """Init all cached commander config settings."""
        c = self
        getBool = c.config.getBool
        getColor = c.config.getColor
        getData = c.config.getData
        getInt = c.config.getInt
        getString = c.config.getString
        c.autoindent_in_nocolor = getBool('autoindent-in-nocolor-mode')
        c.collapse_nodes_after_move = getBool('collapse-nodes-after-move')
        c.collapse_on_lt_arrow = getBool('collapse-on-lt-arrow', default=True)
        c.contractVisitedNodes = getBool('contractVisitedNodes')
        c.fixedWindowPositionData = getData('fixedWindowPosition')
        c.focus_border_color = getColor('focus-border-color') or 'red'
        c.focus_border_command_state_color = getColor('focus-border-command-state-color') or 'blue'
        c.focus_border_overwrite_state_color = (
            getColor('focus-border-overwrite-state-color') or 'green'
        )
        c.focus_border_width = getInt('focus-border-width') or 1  # pixels
        c.forceExecuteEntireBody = getBool('force-execute-entire-body', default=False)
        c.make_node_conflicts_node = getBool('make-node-conflicts-node', default=True)
        c.outlineHasInitialFocus = getBool('outline-pane-has-initial-focus')
        c.page_width = getInt('page-width') or 132
        c.sparse_move = getBool('sparse-move-outline-left')
        c.sparse_find = getBool('collapse-nodes-during-finds')
        c.sparse_spell = getBool('collapse-nodes-while-spelling')
        c.sparse_goto_visible = getBool('collapse-on-goto-first-last-visible', default=False)
        c.stayInTreeAfterSelect = getBool('stayInTreeAfterSelect')
        c.smart_tab = getBool('smart-tab')
        c.tab_width = getInt('tab-width') or -4
        c.target_language = getString('target-language') or 'python'
        c.verbose_check_outline = getBool('verbose-check-outline', default=False)
        c.vim_mode = getBool('vim-mode', default=False)
        c.write_script_file = getBool('write-script-file')

    # @+node:ekr.20090213065933.7: *4* c.setWindowPosition
    def setWindowPosition(self) -> None:
        c = self
        if c.fixedWindowPositionData:
            try:
                aList = [z.strip() for z in c.fixedWindowPositionData if z.strip()]
                w, h, left, t = aList
                c.fixedWindowPosition = [int(w), int(h), int(left), int(t)]
            except Exception:
                g.error('bad @data fixedWindowPosition', repr(self.fixedWindowPosition))
        else:
            c.windowPosition = 500, 700, 50, 50  # width,height,left,top.

    # @+node:ekr.20260619060020.1: *3* @cmd commands
    # @+node:ekr.20250508044308.1: *4* @cmd beautify-tree
    @cmd('beautify-tree')
    def beautify_tree_command(self, event: LeoKeyEvent | None = None) -> None:
        """Undoably beautify c.p and its subtree."""
        c = self
        c.beautify_script_tree(c.p)

    # @+node:ekr.20210530065748.1: *4* @cmd c.execute-general-script
    @cmd('execute-general-script')
    def execute_general_script_command(self, event: LeoKeyEvent | None = None) -> None:
        """
        Execute c.p and all its descendants as a script.

        Create a temp file if c.p is not an @<file> node.

        @data exec-script-commands associates commands with languages.

        @data exec-script-patterns provides patterns to create clickable
        links for error messages.

        Set the cwd before calling the command.
        """
        c, p, tag = self, self.p, 'execute-general-script'

        def get_setting_for_language(setting: str) -> str | None:
            """
            Return the setting from the given @data setting.
            The first colon ends each key.
            """
            for s in c.config.getData(setting) or []:
                key, val = s.split(':', 1)
                if key.strip() == 'language':  # 2025/04/05.
                    return val.strip()
            return None

        # Get the language and extension.
        language = c.getLanguage(p)
        ext = g.app.language_extension_dict.get(language)
        if not ext:
            print(f"{tag}: No extension for {language}")
            return
        # Get the command.
        command = get_setting_for_language('exec-script-commands')
        if not command:
            print(f"{tag}: No command for {language} in @data exec-script-commands")
            return
        # Get the optional pattern.
        regex = get_setting_for_language('exec-script-patterns') or ''
        # Set the directory, if possible.
        if p.isAnyAtFileNode():
            path = c.fullPath(p)
            directory = os.path.dirname(path)
        else:
            directory = ''
        c.general_script_helper(command, ext, language, directory=directory, regex=regex, root=p)

    # @+node:tom.20241014154415.1: *4* @cmd c.execute-external-file
    # @@language python
    @cmd('execute-external-file')
    def execute_external_file(self, event: LeoKeyEvent | None = None) -> None:
        r"""
        # @+<< docstring >>
        # @+node:tom.20241014154415.2: *5* << docstring >>
        Run external files.

        If there is an @language directive in the top node of the file,
        the external processor will be chosen based on it if known.
        Otherwise, the processor will be chosen using the file's extension
        if known.  Otherwise, on Linux a shebang line will be used if the
        external file has one. The candidate processor will be verified
        to be reachable by the shell.

        On Windows, "@language batch" and "@language shell" both will cause
        cmd.exe to be invoked as the file processor. On Linux, "@language
        shell" will cause the system's shell to be invoked. By default, this
        will be bash. If bash is not present, then the environmental variable
        $SHELL will be invoked.

        The processing programs and language file extensions can be
        specified in an @data settings node with the name
        "run-external-processor-map".

        The data in the @data node body must have a PROCESSORS, an
        EXTENSIONS section, and optionally a TERMINAL section,
        looking like this example:

            # A comment line
            # Map file extensions to language names
            EXTENSIONS
            .lua: lua    # Trailing comments allowed
            .rb: ruby

            # Map language names to processor names or paths
            PROCESSORS
            lua: lua
            ruby: C:\Ruby27-x64\bin\ruby.exe

            # Optionally specify a Linux terminal here (e.g., konsole)
            TERMINAL
            # konsole

        Blank lines and lines starting with a "#" are ignored.  If a
        full path to the processor is included, that path will be used.
        Otherwise, the processor must be findable by the shell: this
        normally means it must be on the PATH.

        Any output will be displayed in a newly-opened launching console.

        # @-<< docstring >>
        """
        c = self
        MAP_SETTING_NODE = 'run-external-processor-map'
        # @+others
        # @+node:tom.20241014154415.3: *5* Declarations
        EXECUTE_ARGS = {
            'konsole': '--noclose -e ',
            'gnome-terminal': '-- ',
            'xfce4-terminal': '-- ',
            'mate-terminal': '-- ',
            'tillix': '-- ',
            'lxterminal': '-- ',
            'alacritty': '-e',
            'kitty': '-e',
            'urvxt': '-e',
            'terminator': '-e',
            'xterm': '-e ',
        }

        PREFERRED_TERMINALS = EXECUTE_ARGS.keys()
        # @+node:tom.20241014154415.4: *5* SETTINGS_HELP
        SETTINGS_HELP = r'''The data in the @data node body must have a
        PROCESSORS and an EXTENSIONS section, plus an optional TERMINAL
        section, looking like this example:

            # A comment line
            # Map file extensions to language names
            EXTENSIONS
            .lua: lua   # Trailing comments are allowed
            .rb: ruby

            # Map language names to processor names or paths
            PROCESSORS
            lua: lua
            ruby: C:\Ruby27-x64\bin\ruby.exe

            # Optionally specify a Linux terminal (e.g., konsole) on the
            # line after the "TERMINAL" line.
            TERMINAL

        Blank lines and lines starting with a "#" are ignored.
        '''
        # @+node:tom.20241014154415.5: *5* extension map
        LANGUAGE_EXTENSION_MAP = {
            '.cmd': 'batch',
            '.bat': 'batch',  # We'll get confused if a Linux program uses a .bat extension
            '.jl': 'julia',
            '.lua': 'lua',
            '.ps1': 'powershell',
            '.py': 'python',
            '.pyw': 'python',
            'rb': 'ruby',
        }
        # @+node:tom.20241014154415.6: *5* processor map
        PROCESSORS = {
            'batch': 'cmd.exe',
            'julia': 'julia',
            'lua': 'lua',
            'powershell': 'powershell',
            'ruby': 'ruby',
            'shellscript': 'bash',
        }
        # @+node:tom.20241014154415.7: *5* get_external_maps
        MAP_SETTING_NODE = "run-external-processor-map"

        def get_external_maps() -> tuple[dict, dict, str] | tuple[None, None, str]:
            # @+<< get_external_maps: docstring >>
            # @+node:tom.20241014154415.8: *6* << get_external_maps: docstring >>
            r"""Return processor, extension maps for @data node.

            The data in the @data node body must have a PROCESSORS and an
            EXTENSIONS section, looking like this example:

                # A comment line
                # Map file extensions to language names
                EXTENSIONS
                .lua: lua  # Trailing comments are allowed
                .rb: ruby

                # Map language names to processor names or paths
                PROCESSORS
                lua: lua
                ruby: C:\Ruby27-x64\bin\ruby.exe

                # Specify a particular Linux terminal to use
                # e.g, /usr/bin/konsole
                TERMINAL

            Blank lines and lines starting with a "#" are ignored.  Trailing
            in-line comments are allowed, delineated by "#".

            RETURNS
            a tuple (processor_map, extension_map, terminal)
            """
            # @-<< get_external_maps: docstring >>

            data: list[str] = c.config.getData(MAP_SETTING_NODE)  # Strip comment lines.
            if not data:
                return None, None, ''

            # Allow trailing comments:
            lines = [z.split('#', 1)[0] for z in data]

            def scan_map(kind: str) -> dict[str, str]:
                d = {}
                other_kind = 'PROCESSORS' if kind == 'EXTENSIONS' else 'EXTENSIONS'
                scanning = False
                for line in lines:
                    if not line.strip():
                        continue
                    if kind in line:
                        scanning = True
                    elif other_kind in line:
                        scanning = False
                    elif scanning:
                        # Line format: a: b
                        keyval = line.split(':', 1)
                        if len(keyval) > 1:
                            key = keyval[0].strip()
                            val = keyval[1].strip()
                            d[key] = val
                return d

            # Get terminal value.
            terminal = ''
            found_terminal = False
            for line in lines:
                if found_terminal:
                    if terminal := line.strip():
                        break
                elif 'TERMINAL' in line:
                    found_terminal = True

            # Set the maps.
            processor_map = scan_map('PROCESSORS')
            extension_map = scan_map('EXTENSIONS')
            return processor_map, extension_map, terminal

        # @+node:tom.20241014154415.9: *5* getExeKind
        def getExeKind(ext: str) -> str:
            """
            Return the executable kind (a language) of the external file.

            If there is a language directive in effect, return it,
            otherwise use the file extension.
            """
            return c.getLanguage(c.p) or LANGUAGE_EXTENSION_MAP.get(ext, '')

        # @+node:tom.20241014154415.10: *5* getProcessor
        def getProcessor(language: str, path: str, extension: str) -> str:
            """Return the name or path of a program able to run our external program."""
            processor = ''
            if language == 'python':
                processor = sys.executable
            else:
                if g.isWindows and language == 'shell':
                    return 'cmd.exe'
                processor = PROCESSORS.get(language, '')
                if not processor:
                    if g.isWindows:
                        ftype = get_win_assoc(extension)
                        processor = get_win_processor(ftype)
            # Check to make sure we can run this processor
            if processor:
                proc = which(processor)
                if not proc:
                    processor = ''
            return processor

        # @+node:tom.20241014154415.11: *5* Get Windows File Associations
        def get_win_assoc(extension: str) -> str:
            """Return Windows association for given file extension, or ''.

            The extension must include the dot.
            """
            cmd = f'assoc {extension}'
            # pylint: disable=subprocess-run-check
            proc = subprocess.run(cmd, shell=True, capture_output=True)
            filetype = proc.stdout.decode('utf-8')  # e.g., ".py=Python.File"
            filetype = filetype.split('=')[1] if filetype else ''
            return filetype

        def get_win_processor(filetype: str) -> str:
            r"""Get Windows' idea of the program to use for running this file type.

            Example return from ftype:
                Lua.Script="C:\Program Files (x86)\Lua\5.1\lua.exe" "%1" %*

            ARGUMENT
            filetype -- a file type returned by the assoc command.

            RETURNS
            the processor or ''
            """
            if not filetype:
                return ''
            cmd = f'ftype {filetype}'
            proc = subprocess.run(cmd, shell=True, capture_output=True, check=False)
            ftype_str = proc.stdout.decode('utf-8') or 'none'
            if not ftype_str:
                return ''
            prog_str = ftype_str.split('=')[1]
            return prog_str.split('"')[1]

        # @+node:tom.20241014154415.12: *5* getShell
        def getShell() -> str:
            # Prefer bash unless it is not present - we know its options' names
            shell = 'bash'
            has_bash = which(shell)
            if not has_bash:
                # Need bare shell name, not whole path
                shell = os.environ['SHELL'].split('/')[-1]
            return shell

        # @+node:tom.20241014154415.13: *5* getTerminal
        # @+others
        # @+node:tom.20241014154415.14: *6* getTerminalFromDirectory
        def getTerminalFromDirectory(dir: str) -> str:
            BAD_NAMES = (
                'xdg-terminal',
                'setterm',
                'ppmtoterm',
                'koi8rxterm',
                'rofi-sensible-terminal',
                'x-terminal-emulator',
            )
            TERM_STRINGS = ('*-terminal', '*term')
            for ts in TERM_STRINGS:
                cmd = f'find {dir} -type f -name {ts}'
                proc = subprocess.run(cmd, shell=True, capture_output=True, check=False)
                terminals = proc.stdout.decode('utf-8')
                for t in terminals.splitlines():
                    bare_term = t.split('/')[-1]
                    if bare_term not in BAD_NAMES:
                        return t
            return ''

        # @+node:tom.20241014154415.15: *6* getCommonTerminal
        def getCommonTerminal(names: str | Iterable[str]) -> str:
            """Return a terminal name given candidate names.

            ARGUMENT
            names -- a string containing one name, or a sequence of strings.

            RETURNS
            a path of an existing terminal, if found, else an empty string
            """
            term = ''
            if isinstance(names, str):
                names = (names,)
            term = ''
            for name in names:
                if term := which(name) or '':
                    break
            return term

        # @-others

        def getTerminal() -> str:
            if term := os.environ.get('TERMINAL', ''):
                return term
            return (
                getCommonTerminal(PREFERRED_TERMINALS)
                or getTerminalFromDirectory('/usr/bin')
                or getTerminalFromDirectory('/usr/local/bin')
                or getTerminalFromDirectory('/bin')
                or ''
            )

        # @+node:tom.20241014154415.16: *5* getTermExecuteCmd
        def getTermExecuteCmd(terminal: str) -> str:
            """Given a terminal's name, find the command line arg to launch a program.

            If the terminal's arg is known, use that. Otherwise, try "--help".
            If that fails, see try "--help-all".  If neither has an argument
            or switch for "Execute", give up and assume the arg is "-x".
            """
            HELP_CMDS = ('-h', '--help', '--help-all')
            EXECUTESTR = 'execute'

            if terminal in EXECUTE_ARGS:
                return EXECUTE_ARGS[terminal]

            # @+others
            # @+node:tom.20241014154415.17: *6* get_help_message (c.execute-external-file)
            def get_help_message(terminal: str, help_cmd: str) -> str:
                cmd = f'{terminal} {help_cmd}'
                proc = subprocess.run(cmd, shell=True, capture_output=True, check=False)
                msg = proc.stdout.decode('utf-8')
                if not msg:
                    # g.es('error:', proc.stderr.decode('utf-8'))
                    return ''
                return msg

            # @+node:tom.20241014154415.18: *6* find_ex_arg
            def find_ex_arg(help_msg: str) -> str:
                for line in help_msg.splitlines():
                    if '--command' in line:
                        return '--command'
                    if '-e' in line:
                        return '-e'
                    if EXECUTESTR in line.lower():
                        fields = line.lstrip().split()
                        # There may be more than one arg; if so, use the first one
                        arg = fields[0]
                        # May have trailing comma; remove it
                        args = arg.split(',')
                        return args[0]
                return ''

            # @-others

            for cmd in HELP_CMDS:
                msg = get_help_message(terminal, cmd)
                if arg := find_ex_arg(msg):
                    if arg.startswith('--'):
                        arg += '='
                    else:
                        arg += ' '
                    break
            else:
                arg = '-x '  # We can only hope
            return arg

        # @+node:tom.20241014154415.19: *5* checkShebang
        def checkShebang(path: str) -> bool:
            """Return True if file begins with a shebang line, else False."""
            path = g.finalize(path)
            with open(path, encoding='utf-8') as f:
                first_line = f.readline()
            return first_line.startswith('#!')

        # @+node:tom.20241014154415.20: *5* runFile @cmd c.execute-external-file
        def runfile(fullpath: str, processor: str, terminal: str) -> None:
            direc: str = os.path.expanduser(os.path.dirname(fullpath))
            if g.isWindows:
                fullpath = fullpath.replace('/', '\\')
                if processor:
                    if processor == 'cmd.exe':
                        cmd = ['start', processor, '/k', fullpath]
                    else:
                        cmd = ['start', 'cmd.exe', '/k', processor, fullpath]
                    subprocess.Popen(cmd, shell=True)
                else:
                    g.es('Unknown processor', fullpath, color='red')
            elif g.isMac:
                g.es('Cannot launch external files on a Mac yet', color='red')
            else:  # Presumably Linux
                fullpath = fullpath.replace('\\', '/')
                term = terminal or getTerminal()

                if not term:
                    g.es(
                        'Cannot find a terminal to launch the external file',
                        color='red',
                    )
                    g.es(
                        f'   You can specify a terminal in an "@data {MAP_SETTING_NODE}" setting node'
                    )
                    g.es('  ', SETTINGS_HELP)
                    return

                shell_name = getShell()
                execute_arg = getTermExecuteCmd(term)
                if (not processor) and checkShebang(fullpath):
                    cmd_ = (
                        f"""{term} {execute_arg}"{shell_name} -c 'cd {direc}; {fullpath} ;read'" """
                    )
                elif processor:
                    cmd_ = f"""{term} {execute_arg}"{shell_name} -c 'cd {direc};{processor} {fullpath} ;read'" """
                else:
                    g.es(f'No processor for {fullpath}', color='red')
                    return

                subprocess.Popen(cmd_, shell=True, start_new_session=True)

        # @-others

        language, path = None, None
        root, path = c.gotoCommands.find_root(c.p)
        if root and path:
            processor_map, extension_map, terminal = get_external_maps()
            if extension_map:
                LANGUAGE_EXTENSION_MAP = LANGUAGE_EXTENSION_MAP | extension_map
            if processor_map:
                PROCESSORS = PROCESSORS | processor_map
            _, ext = os.path.splitext(path)

            # Check terminal from MAP_SETTING_NODE setting
            if setting_terminal := terminal or '':
                terminal = which(terminal) or ''
                if not terminal:
                    g.es(f'Cannot find terminal specified in setting: {setting_terminal}')
                    g.es('Trying an alternative')
                    terminal = getTerminal() or ''
                    g.es('using', terminal)

            path = c.fullPath(root)
            language = getExeKind(ext)
            processor = getProcessor(language, path, ext)
            runfile(path, processor, terminal)
        else:
            g.es('Cannot find an @- file', color='red')

    # @+node:vitalije.20190924191405.1: *4* @cmd execute-pytest
    @cmd('execute-pytest')
    def execute_pytest(self, event: LeoKeyEvent | None = None) -> None:
        """Using pytest, execute all @test nodes for p, p's parents and p's subtree."""
        c = self

        def it(p: Position) -> PositionGenerator:
            for p1 in p.self_and_parents():
                if p1.h.startswith('@test '):
                    yield p1
                    return
            for p1 in p.subtree():
                if p1.h.startswith('@test '):
                    yield p1

        try:
            for p in it(c.p):
                self.execute_single_pytest(p)
        except ImportError:
            g.es('pytest needs to be installed')

    def execute_single_pytest(self, p: Position) -> None:
        c = self
        from _pytest.config import get_config
        from _pytest.assertion.rewrite import rewrite_asserts
        import ast

        cfg = get_config()
        script = g.getScript(c, p, useSentinels=False) + (
            '\n'
            'ls = dict(locals())\n'
            'failed = 0\n'
            'for x in ls:\n'
            '    if x.startswith("test_") and callable(ls[x]):\n'
            '        try:\n'
            '            ls[x]()\n'
            '        except AssertionError as e:\n'
            '            failed += 1\n'
            '            g.es(f"-------{p.h[6:].strip()}/{x} failed---------")\n'
            '            g.es(str(e))\n'
            'if failed == 0:\n'
            '    g.es("all tests passed")\n'
            'else:\n'
            '    g.es(f"failed:{failed} tests")\n'
        )

        fname = g.finalize_join(g.app.homeLeoDir, 'leoPytestScript.py')
        with open(fname, 'wt', encoding='utf8') as out:
            out.write(script)
        tree = ast.parse(script, filename=fname)
        # A mypy bug? the script can be str.
        rewrite_asserts(tree, script, config=cfg)  # type:ignore
        co = compile(tree, fname, "exec", dont_inherit=True)
        sys.path.insert(0, os.getcwd())
        sys.path.insert(0, g.os_path_dirname(c.fileName()))  # per SegundoBob
        try:
            exec(co, {'c': c, 'g': g, 'p': p})
        except KeyboardInterrupt:
            g.es('interrupted')
        except Exception:
            g.handleScriptException(c, p)
        finally:
            del sys.path[:2]

    # @+node:ekr.20171123135625.4: *4* @cmd execute-script & public helpers
    @cmd('execute-script')
    def executeScript(
        self,
        *,
        event: LeoKeyEvent | None = None,  # not used.
        args: Args | None = None,
        p: Position | None = None,
        script: str = '',
        useSelectedText: bool = True,
        define_g: bool = True,
        define_name: str = '__main__',
        silent: bool = False,
        namespace: dict | None = None,
        raiseFlag: bool = False,
        runPyflakes: bool = True,
    ) -> Value:
        # @+<< executeScript: docstring >>
        # @+node:ekr.20250508025320.1: *5* << executeScript: docstring >>
        """
        Execute a *Leo* script, written in python.
        Keyword args:
        args=None               Not None: set script_args in the execution environment.
        p=None                  Get the script from p.b, unless script is given.
        script=None             None: use script in p.b or c.p.b
        useSelectedText=True    False: use all the text in p.b or c.p.b.
        define_g=True           True: define g for the script.
        define_name='__main__'  Not None: define the name symbol.
        silent=False            No longer used.
        namespace=None          Not None: execute the script in this namespace.
        raiseFlag=False         True: reraise any exceptions.
        runPyflakes=True        True: run pyflakes if allowed by setting.
        """
        # @-<< executeScript: docstring >>
        c = self
        p = p or c.p
        language = g.findLanguageDirectives(c, p)
        script_p = p or c.p  # Only for error reporting below.

        # Compute flags
        # fmt: off
        beautify_flag = (
            language == 'python'
            and c.config.getBool('beautify-python-code-on-write', default=False)
        )
        pyflakes_flag = (
            runPyflakes
            and language == 'python'
            and c.config.getBool('run-pyflakes-on-write', default=False)
        )
        # fmt: on
        if not script and language not in ('jupytext', 'python'):  # #4197, #4226.
            w = c.frame.body.wrapper
            # For non-python languages...
            valid = (
                # There must be a selection,
                w and w.getSelectedText().strip()
                # and the selection must apply to p,
                and p == c.p
                # and the 'useSelectedText` kwarg must be True,
                and useSelectedText
                # and script *won't* be expanded to the entire body.
                and not c.forceExecuteEntireBody
            )  # fmt: skip
            if not valid:
                message = f"Must select text to execute {language} script"
                g.es_print(message, color='blue')
                return None

        # #4350: Optionally beautify each node in script_p's tree separately.
        if beautify_flag and not script and not g.unitTesting:
            c.beautify_script_tree(c.p)

        # Compute the script if necessary.
        if not script:
            if c.forceExecuteEntireBody:
                useSelectedText = False
            script = g.getScript(c, p, useSelectedText=useSelectedText)

        # #532: Optionally check all scripts with pyflakes.
        if pyflakes_flag and not g.unitTesting:
            from leo.commands import checkerCommands as cc

            prefix = (
                'c,g,p,script_gnx=None,None,None,None;'
                'assert c and g and p and script_gnx;\n'
            )  # fmt: skip
            cc.PyflakesCommand(c).check_script(script_p, prefix + script)

        # Execute the script!
        self.redirectScriptOutput()
        oldLog = g.app.log
        callResult = None
        try:
            log = c.frame.log
            g.app.log = log
            if script.strip():
                sys.path.insert(0, os.getcwd())
                sys.path.insert(0, g.os_path_dirname(c.fileName()))  # per SegundoBob
                script += '\n'  # Make sure we end the script properly.
                try:
                    if not namespace or not namespace.get('script_gnx'):
                        namespace = namespace or {}
                        namespace.update(script_gnx=script_p.gnx)
                    # We *always* execute the script with p = c.p.
                    callResult = c.executeScriptHelper(
                        args or [], define_g, define_name, namespace, script
                    )
                except KeyboardInterrupt:
                    g.es('interrupted')
                except Exception:
                    if raiseFlag:
                        raise
                    g.handleScriptException(c, script_p)
                finally:
                    del sys.path[0]
                    del sys.path[0]
            else:
                tabName = log and hasattr(log, 'tabName') and log.tabName or 'Log'
                g.warning("no script selected", tabName=tabName)
        finally:
            g.app.log = oldLog
            self.unredirectScriptOutput()
        return callResult

    # @+node:ekr.20171123135625.5: *5* c.executeScriptHelper
    def executeScriptHelper(
        self,
        args: list,
        define_g: bool,
        define_name: str,
        namespace: dict,
        script: str,
    ) -> Value:
        c = self
        if c.p:
            p = c.p.copy()  # *Always* use c.p and pass c.p to script.
            c.setCurrentDirectoryFromContext(p)
        else:
            p = None
        d = {'c': c, 'g': g, 'input': g.input_, 'p': p} if define_g else {}
        if define_name:
            d['__name__'] = define_name
        d['script_args'] = args or []
        d['script_gnx'] = g.app.scriptDict.get('script_gnx')
        if namespace:
            d.update(namespace)
        # A kludge: reset c.inCommand here to handle the case where we *never* return.
        # (This can happen when there are multiple event loops.)
        # This does not prevent zombie windows if the script puts up a dialog...
        try:
            c.inCommand = False
            g.inScript = g.app.inScript = True  # g.inScript is a synonym for g.app.inScript.
            if c.write_script_file:
                scriptFile = self.writeScriptFile(script)
                exec(compile(script, scriptFile, 'exec'), d)
            else:
                exec(script, d)
            # Return the optional value of a 'result' global, if defined, to be returned.
            return d.get("result")
        finally:
            g.inScript = g.app.inScript = False

    # @+node:ekr.20171123135625.6: *5* c.redirectScriptOutput
    def redirectScriptOutput(self) -> None:
        c = self
        if c.exists and c.config.getBool('redirect-execute-script-output-to-log-pane'):
            g.redirectStdout()  # Redirect stdout
            g.redirectStderr()  # Redirect stderr

    # @+node:ekr.20171123135625.7: *5* c.setCurrentDirectoryFromContext
    def setCurrentDirectoryFromContext(self, p: Position) -> None:
        c = self
        path = c.getPath(p)
        curDir = g.os_path_abspath(os.getcwd())
        if path and path != curDir:
            try:
                os.chdir(path)
            except Exception:
                pass

    # @+node:ekr.20171123135625.8: *5* c.unredirectScriptOutput
    def unredirectScriptOutput(self) -> None:
        c = self
        if c.exists and c.config.getBool('redirect-execute-script-output-to-log-pane'):
            g.restoreStderr()
            g.restoreStdout()

    # @+node:ekr.20080514131122.12: *4* @cmd recolor (c.recolorCommand)
    @cmd('recolor')
    def recolorCommand(self, event: LeoKeyEvent | None = None) -> None:
        """Force a full recolor."""
        c = self
        colorer = c.frame.body.colorizer
        if hasattr(colorer, 'force_recolor'):
            # For Qt (jEdit) and pygments.
            colorer.force_recolor()
        else:
            # Default: set all text, retaining the selection.
            wrapper = c.frame.body.wrapper
            i, j = wrapper.getSelectionRange()
            ins = wrapper.getInsertPoint()
            wrapper.setAllText(c.p.b)
            wrapper.setSelectionRange(i, j, insert=ins)

    # @+node:ekr.20260619021703.1: *4* @cmd redraw (c.redraw_command)
    @cmd('redraw')
    def redraw_command(self, event: LeoKeyEvent | None = None) -> None:
        if c := event.get('c') if event else None:
            c.redraw()

    # @+node:ekr.20171124100654.1: *3* c.API
    # These methods are a fundamental, unchanging, part of Leo's API.
    # @+node:ekr.20091001141621.6061: *4* c.Generators
    # @+node:ekr.20091001141621.6043: *5* c.all_nodes & all_unique_nodes
    def all_nodes(self) -> VNodeGenerator:
        """A generator returning all vnodes in the outline, in outline order."""
        c = self
        for p in c.all_positions():
            yield p.v

    def all_unique_nodes(self) -> VNodeGenerator:
        """A generator returning each vnode of the outline."""
        c = self
        for p in c.all_unique_positions(copy=False):
            yield p.v

    # Compatibility with old code...

    all_vnodes_iter = all_nodes
    all_unique_vnodes_iter = all_unique_nodes

    # @+node:ekr.20091001141621.6044: *5* c.all_positions
    def all_positions(self, copy: bool = True) -> PositionGenerator:
        """A generator return all positions of the outline, in outline order."""
        c = self
        p = c.rootPosition()
        while p:
            yield p.copy() if copy else p
            p.moveToThreadNext()

    # Compatibility with old code...

    all_positions_iter = all_positions
    allNodes_iter = all_positions
    safe_all_positions = all_positions

    # @+node:ekr.20191014093239.1: *5* c.all_positions_for_v
    def all_positions_for_v(self, v: VNode, stack: list[tuple] | None = None) -> PositionGenerator:
        """
        Generates all positions p in this outline where p.v is v.

        Should be called with stack=None.

        The generated positions are not necessarily in outline order.

        By Виталије Милошевић (Vitalije Milosevic).
        """
        c = self

        if stack is None:
            stack = []

        if not isinstance(v, VNode):
            g.es_print(f"not a VNode: {v!r}")
            return  # Stop the generator.

        def allinds(v: VNode, target_v: VNode) -> Generator[int, None, None]:
            """Yield all indices i such that v.children[i] == target_v."""
            for i, x in enumerate(v.children):
                if x is target_v:
                    yield i

        def stack2pos(stack: list[tuple]) -> Position:
            """Convert the stack to a position."""
            v, i = stack[-1]
            return Position(v, i, stack[:-1])

        for v2 in set(v.parents):
            for i in allinds(v2, v):
                stack.insert(0, (v, i))
                if v2 is c.hiddenRootNode:
                    yield stack2pos(stack)
                else:
                    yield from c.all_positions_for_v(v2, stack)
                stack.pop(0)

    # @+node:ekr.20161120121226.1: *5* c.all_roots
    def all_roots(self, copy: bool = True, predicate: Callable | None = None) -> PositionGenerator:
        """
        A generator yielding *all* the root positions in the outline that
        satisfy the given predicate. p.isAnyAtFileNode is the default
        predicate.
        """
        c = self
        if predicate is None:
            # pylint: disable=function-redefined

            def predicate(p: Position) -> bool:
                return p.isAnyAtFileNode()

        p = c.rootPosition()
        while p:
            if predicate(p):
                yield p.copy()  # 2017/02/19
                p.moveToNodeAfterTree()
            else:
                p.moveToThreadNext()

    # @+node:ekr.20091001141621.6062: *5* c.all_unique_positions
    def all_unique_positions(self, copy: bool = True) -> PositionGenerator:
        """
        A generator return all positions of the outline, in outline order.
        Returns only the first position for each vnode.
        """
        c = self
        p = c.rootPosition()
        seen = set()
        while p:
            if p.v in seen:
                p.moveToNodeAfterTree()
            else:
                seen.add(p.v)
                yield p.copy() if copy else p
                p.moveToThreadNext()

    # Compatibility with old code...

    all_positions_with_unique_vnodes_iter = all_unique_positions

    # @+node:ekr.20161120125322.1: *5* c.all_unique_roots
    def all_unique_roots(
        self, copy: bool = True, predicate: Callable | None = None
    ) -> PositionGenerator:
        """
        A generator yielding all unique root positions in the outline that
        satisfy the given predicate. p.isAnyAtFileNode is the default
        predicate.
        """
        c = self
        if predicate is None:
            # pylint: disable=function-redefined

            def predicate(p: Position) -> bool:
                return p.isAnyAtFileNode()

        seen = set()
        p = c.rootPosition()
        while p:
            if p.v not in seen and predicate(p):
                seen.add(p.v)
                yield p.copy() if copy else p
                p.moveToNodeAfterTree()
            else:
                p.moveToThreadNext()

    # @+node:felix.20250908230144.1: *5* c.all_root_children
    def all_root_children(self, copy: bool = True) -> PositionGenerator:
        """
        A generator that returns all the (hidden-root's) top children Positions.
        """
        c = self
        p = c.rootPosition()  # Return this first position, and its siblings
        while p:
            yield p.copy() if copy else p
            p.moveToNext()

    # @+node:ekr.20060906211747: *4* c.Getters
    # @+node:ekr.20040803140033: *5* c.currentPosition
    def currentPosition(self) -> Position:
        """
        Return a copy of the presently selected position or None.
        As a result, c.p.copy() is never necessary.
        """
        c = self
        return c._currentPosition.copy() if c._currentPosition else c.rootPosition()

    # For compatibility with old scripts...

    currentVnode = currentPosition

    # @+node:ekr.20190506060937.1: *5* c.dumpExpanded
    @cmd('dump-expanded')
    def dump_expanded(self, event: LeoKeyEvent | None = None) -> None:
        """Print all non-empty v.expandedPositions lists."""
        c = event.get('c') if event else None
        if not c:
            return
        g.es_print('dump-expanded...')
        for p in c.all_positions():
            if p.v.expandedPositions:
                g.printObj(p.v.expandedPositions, indent=p.level(), tag=p.h)

    # @+node:ekr.20040306220230.1: *5* c.headline_wrapper
    def headline_wrapper(self, p: Position) -> Widget | None:
        c = self
        return c.frame.tree.headline_wrapper(p) if p else None

    edit_widget = headline_wrapper  # Compatibility.

    # @+node:ekr.20031218072017.2986: *5* c.fileName & relativeFileName & shortFileName
    # Compatibility with scripts

    def fileName(self) -> str:
        s = self.mFileName or ""
        if g.isWindows:
            s = s.replace('\\', '/')
        return s

    def relativeFileName(self) -> str:
        return self.mRelativeFileName or self.mFileName

    def shortFileName(self) -> str:
        return g.shortFileName(self.mFileName)

    shortFilename = shortFileName

    # @+node:ekr.20070615070925.1: *5* c.firstVisible
    def firstVisible(self) -> Position:
        """Move to the first visible node of the present chapter or hoist."""
        c, p = self, self.p
        while 1:
            back = p.visBack(c)
            if back and back.isVisible(c):
                p = back
            else:
                break
        return p

    # @+node:ekr.20171123135625.29: *5* c.getBodyLines
    def getBodyLines(
        self,
    ) -> tuple[str, list[str], str, tuple | None, tuple | None]:
        """
        Return (head, lines, tail, oldSel, oldYview).

        - head: string containing all the lines before the selected text (or the
          text before the insert point if no selection)
        - lines: list of lines containing the selected text
          (or the line containing the insert point if no selection)
        - after: string containing all lines after the selected text
          (or the text after the insert point if no  selection)
        - oldSel: tuple containing the old selection range, or None.
        - oldYview: int containing the old y-scroll value, or None.
        """
        c = self
        body = c.frame.body
        w = body.wrapper
        oldYview = w.getYScrollPosition()
        # Note: lines is the entire line containing the insert point if no selection.
        head, s, tail = body.getSelectionLines()
        lines = g.splitLines(s)  # Retain the newlines of each line.
        # Expand the selection.
        i = len(head)
        j = len(head) + len(s)
        oldSel = i, j
        return head, lines, tail, oldSel, oldYview  # string,list,string,tuple,int.

    # @+node:ekr.20250405040620.1: *5* c.getDelims
    # Use a regex to avoid allocating temp strings.
    at_comment_pattern = re.compile(r'^@comment\s+(.*)$', re.MULTILINE)

    def getDelims(self, p: Position) -> tuple[str, str, str]:
        c = self
        # The headline has higher precedence because it is more visible.
        for p2 in p.self_and_parents():
            for s in (p2.h, p2.b):
                for m in c.at_comment_pattern.finditer(s):
                    comment = m.group(1)
                    return g.set_delims_from_string(comment)

        # Return the default comment delims.
        default_language = c.getLanguage(p) or c.target_language or 'python'
        return g.set_delims_from_language(default_language)

    # @+node:ekr.20250404072805.1: *5* c.getEncoding
    # Use a regex to avoid allocating temp strings.
    at_encoding_pattern = re.compile(r'^@encoding\s+([\w_-]+)', re.MULTILINE)

    def getEncoding(self, p: Position) -> str:
        """
        Scan p and all ancestors for the first @encoding direcive.

        Return c.config.default_derived_file_encoding or 'utf-8' by default.
        """
        c = self
        # The headline has higher precedence because it is more visible.
        for p2 in p.self_and_parents():
            for s in (p2.h, p2.b):
                for m in c.at_encoding_pattern.finditer(s):
                    encoding = m.group(1)
                    if g.isValidEncoding(encoding):
                        return encoding
                    g.error("invalid @encoding:", encoding)
        return c.config.default_derived_file_encoding or 'utf-8'

    # @+node:ekr.20250405141653.1: *5* c.getLanguage
    def getLanguage(self, p: Position) -> str:
        """
        Return the language in effect at node p, checking that the language is valid."""
        v0 = p.v
        assert v0
        assert p.v
        seen: set[VNode]

        # The same generator as in v.setAllAncestorAtFileNodesDirty.
        # Original idea by Виталије Милошевић (Vitalije Milosevic).
        # Modified by EKR.

        def v_and_parents(v: VNode) -> VNodeGenerator:
            if v in seen:
                return
            seen.add(v)
            yield v
            for parent_v in v.parents:
                if parent_v not in seen:
                    yield from v_and_parents(parent_v)

        # First, see if p contains any @language directive.
        if language := g.findFirstValidAtLanguageDirective(p.b):
            return language

        # Passes 1 and 2: Search body text for unambiguous @language directives.

        # Pass 1: Search body text in direct parents for unambiguous @language directives.
        for p2 in p.self_and_parents(copy=False):
            languages = g.findAllValidLanguageDirectives(p2.v.b)
            if len(languages) == 1:  # An unambiguous language
                return languages[0]

        # Pass 2: Search body text in extended parents for unambiguous @language directives.
        seen = set([v0.context.hiddenRootNode])
        for v in v_and_parents(v0):
            languages = g.findAllValidLanguageDirectives(v.b)
            if len(languages) == 1:  # An unambiguous language
                return languages[0]

        # Passes 3 & 4: Use the file extension in @<file> nodes.

        def get_language_from_headline(v: VNode) -> str:
            """Return the extension for @<file> nodes."""
            if v.isAnyAtFileNode():
                name = v.anyAtFileNodeName()
                _, ext = g.os_path_splitext(name)
                ext = ext[1:]  # strip the leading period.
                language = g.app.extension_dict.get(ext, '')
                if g.isValidLanguage(language):
                    return language
            return ''

        # Pass 3: Use file extension in headline of @<file> in direct parents.
        for p2 in p.self_and_parents(copy=False):
            if language := get_language_from_headline(p2.v):
                return language

        # Pass 4: Use file extension in headline of @<file> nodes in extended parents.
        seen = set([v0.context.hiddenRootNode])
        for v in v_and_parents(v0):
            assert v
            if language := get_language_from_headline(v):
                return language

        # Return the default language for the commander.
        c = p.v.context
        return c.target_language or 'python'

    # @+node:ekr.20250405053842.1: *5* c.getLineEnding
    # Use a regex to avoid allocating temp strings.
    at_lineending_pattern = re.compile(r'^@lineending\s+([\w]+)', re.MULTILINE)

    def getLineEnding(self, p: Position) -> str:
        """
        Scan p and all ancestors for the first @lineending direcive.
        Return None (*not* '\n') by default.
        """
        c = self
        # The headline has higher precedence because it is more visible.
        for p2 in p.self_and_parents():
            for s in (p2.h, p2.b):
                for m in c.at_lineending_pattern.finditer(s):
                    ending = m.group(1)
                    if ending in ("cr", "crlf", "lf", "nl", "platform"):
                        return g.getOutputNewline(name=ending)
        return ''

    # @+node:ekr.20250404153234.1: *5* c.getPageWidth
    # Use a regex to avoid allocating temp strings.
    at_pagewidth_pattern = re.compile(r'^@pagewidth\s+(-?[0-9]+)', re.MULTILINE)

    def getPageWidth(self, p: Position) -> int:
        """
        Scan p.b and all ancestors for the first @pagewith direcive.

        Return c.page_width by default.
        """
        c = self
        # The headline has higher precedence because it is more visible.
        for p2 in p.self_and_parents():
            for s in (p2.h, p2.b):
                for m in c.at_pagewidth_pattern.finditer(s):
                    width = m.group(1)
                    try:
                        return int(width)
                    except ValueError:
                        g.error("ignoring m.group(0)")
        return c.page_width

    # @+node:ekr.20250404021710.1: *5* c.getPath & helper
    def getPath(self, p: Position) -> str:
        """
        Scan for @path directives in p and all its direct ancestors.

        Return an absolute path or a reasonable default.
        """
        c = self
        paths = []
        for p2 in p.self_and_parents():
            if path := c.getPathFromNode(p2):
                paths.append(path)

        # Add absbase and reverse the list.
        absbase = g.os_path_dirname(c.fileName()) if c.fileName() else g.app.homeDir
        paths.append(absbase)
        paths.reverse()

        # Compute the full, effective, absolute path.
        path = g.finalize_join(*paths)
        return path

    # @+node:ekr.20250404014820.1: *6* c.getPathFromNode
    # Use a regex to avoid allocating temp strings.
    # https://en.wikipedia.org/wiki/Filename
    at_path_pattern = re.compile(r'^@path\s+(.+)$', re.MULTILINE)

    def getPathFromNode(self, p: Position) -> str | None:
        """
        Scan p.h then p.b for @path directives.
        """
        c = self
        c.scanAtPathDirectivesCount += 1  # An important statistic.

        def get_path(m: re.Match) -> str | None:
            return g.stripPathCruft(m.group(1)) if m else None

        # The headline has higher precedence because it is more visible.
        paths: list[str] = []
        for kind, s in (('head', p.h), ('body', p.b)):
            for m in c.at_path_pattern.finditer(s):
                if kind == 'body' and p.isAtFileNode():
                    message = '@path is not allowed in the body text of @file nodes\n'
                    g.print_unique_message(message)
                elif path := get_path(m):
                    paths.append(path)
            if paths:
                break
        if len(paths) > 1:
            message = (
                f"Multiple @path directives in {p.h!r}\n"
                f"Using the first path: @path {paths[0]}"
            )  # fmt: skip
            g.print_unique_message(message)
        return paths[0] if paths else None

    # @+node:ekr.20250404153250.1: *5* c.getTabWidth
    # Use a regex to avoid allocating temp strings.
    at_tabwidth_pattern = re.compile(r'^@tabwidth\s+(-?[0-9]+)', re.MULTILINE)

    def getTabWidth(self, p: Position) -> int:
        """
        Scan p.b and all ancestors for the first @encoding direcive.

        Return c.tab_width by default.
        """
        c = self
        # The headline has higher precedence because it is more visible.
        for p2 in p.self_and_parents():
            for s in (p2.h, p2.b):
                for m in c.at_tabwidth_pattern.finditer(s):
                    width = m.group(1)
                    try:
                        return int(width)
                    except ValueError:
                        g.error("ignoring m.group(0)")
        return c.tab_width

    # @+node:ekr.20250405143421.1: *5* c.getWrap
    # Use a regex to avoid allocating temp strings.
    at_wrap_pattern = re.compile(r'^@wrap', re.MULTILINE)
    at_nowrap_pattern = re.compile(r'^@nowrap', re.MULTILINE)

    def getWrap(self, p: Position) -> int:
        """
        Scan p.b and all ancestors for @wrap and @nowrap directives.
        Return @bool body-pane-wraps by default.
        """
        c = self
        # The headline has higher precedence because it is more visible.
        for p2 in p.self_and_parents():
            for s in (p2.h, p2.b):
                if c.at_wrap_pattern.search(s) is not None:
                    return True
                if c.at_nowrap_pattern.search(s) is not None:
                    return False
        return c.config.getBool("body-pane-wraps")

    # @+node:ekr.20040803112200: *5* c.is...Position
    # @+node:ekr.20040803155551: *6* c.currentPositionIsRootPosition
    def currentPositionIsRootPosition(self) -> bool:
        """Return True if the current position is the root position.

        This method is called during idle time, so not generating positions
        here fixes a major leak.
        """
        c = self
        root = c.rootPosition()
        return bool(c._currentPosition and root and c._currentPosition == root)

    # @+node:ekr.20040803160656: *6* c.currentPositionHasNext
    def currentPositionHasNext(self) -> bool:
        """Return True if the current position is the root position.

        This method is called during idle time, so not generating positions
        here fixes a major leak.
        """
        c = self
        current = c._currentPosition
        return bool(current and current.hasNext())

    # @+node:ekr.20040803112450: *6* c.isCurrentPosition
    def isCurrentPosition(self, p: Position) -> bool:
        c = self
        if p is None or c._currentPosition is None:
            return False
        return p == c._currentPosition

    # @+node:ekr.20040803112450.1: *6* c.isRootPosition
    def isRootPosition(self, p: Position) -> bool:
        c = self
        root = c.rootPosition()
        return bool(p and root and p == root)

    # @+node:ekr.20031218072017.2987: *5* c.isChanged
    def isChanged(self) -> bool:
        return self.changed

    # @+node:ekr.20210901104900.1: *5* c.lastPosition
    def lastPosition(self) -> Position:
        c = self
        p = c.rootPosition()
        while p.hasNext():
            p.moveToNext()
        while p.hasThreadNext():
            p.moveToThreadNext()
        return p

    # @+node:ekr.20140106215321.16676: *5* c.lastTopLevel
    def lastTopLevel(self) -> Position:
        """Return the last top-level position in the outline."""
        c = self
        p = c.rootPosition()
        while p.hasNext():
            p.moveToNext()
        return p

    # @+node:ekr.20031218072017.4146: *5* c.lastVisible
    def lastVisible(self) -> Position:
        """Move to the last visible node of the present chapter or hoist."""
        c, p = self, self.p
        while 1:
            next = p.visNext(c)
            if next and next.isVisible(c):
                p = next
            else:
                break
        return p

    # @+node:ekr.20040307104131.3: *5* c.positionExists
    def positionExists(
        self, p: Position | None, root: Position | None = None, trace: bool = False
    ) -> bool:
        """Return True if a position exists in c's tree"""
        if not p or not p.v:
            return False

        rstack = root.stack + [(root.v, root._childIndex)] if root else []
        pstack = p.stack + [(p.v, p._childIndex)]

        if len(rstack) > len(pstack):
            return False

        par = self.hiddenRootNode
        for j, x in enumerate(pstack):
            if j < len(rstack) and x != rstack[j]:
                return False
            v, i = x
            if i >= len(par.children) or v is not par.children[i]:
                return False
            par = v
        return True

    # @+node:ekr.20160427153457.1: *6* c.dumpPosition
    def dumpPosition(self, p: Position) -> None:
        """Dump position p and it's ancestors."""
        g.trace('=====', p.h, p._childIndex)
        for i, data in enumerate(p.stack):
            v, childIndex = data
            print(f"{i} {childIndex} {v._headString}")

    # @+node:ekr.20040803140033.2: *5* c.rootPosition
    _rootCount = 0

    def rootPosition(self) -> Position:
        """
        Return a new *copy* of the root position.

        New in Leo 6.8.10: Return an empty Position (p.v == None) instead of None.

        No valid existing code will break because the only valid tests for Positions are:

            if p:
            if not p:
        """
        c = self
        v = c.hiddenRootNode.children[0] if c.hiddenRootNode.children else cast(VNode, None)
        return Position(v=v, childIndex=0, stack=None)

    # For compatibility with old scripts...

    rootVnode = rootPosition
    findRootPosition = rootPosition

    # @+node:ekr.20131017174814.17480: *5* c.shouldBeExpanded
    def shouldBeExpanded(self, p: Position) -> bool:
        """Return True if the node at position p should be expanded."""
        c, v = self, p.v
        if not p.hasChildren():
            return False
        # Always clear non-existent positions.
        assert p.v
        assert v
        v.expandedPositions = [z for z in v.expandedPositions if c.positionExists(z)]
        if not p.isCloned():
            # Do not call p.isExpanded here! It calls this method.
            return p.v.isExpanded()
        if p.isAncestorOf(c.p):
            return True
        for p2 in v.expandedPositions:
            if p == p2:
                return True
        return False

    # @+node:ekr.20070609122713: *5* c.visLimit
    def visLimit(self) -> tuple[Position | None, bool]:
        """
        Return the topmost visible node.
        This is affected by chapters and hoists.
        """
        c = self
        cc = c.chapterController
        if c.hoistStack:
            bunch = c.hoistStack[-1]
            p = bunch.p
            limitIsVisible = not cc or not p.h.startswith('@chapter')
            return p, limitIsVisible
        return None, False

    # @+node:tbrown.20091206142842.10296: *5* c.vnode2allPositions
    def vnode2allPositions(self, v: VNode) -> list[Position]:
        """
        Given a VNode v, find all valid positions p such that p.v = v.

        Not really all, just all for each of v's distinct immediate parents.
        """
        c = self
        context = v.context  # v's commander.
        assert c == context
        positions = []
        for immediate in v.parents:
            if v in immediate.children:
                n = immediate.children.index(v)
            else:
                continue
            stack = [(v, n)]
            while immediate.parents:
                parent = immediate.parents[0]
                if immediate in parent.children:
                    n = parent.children.index(immediate)
                else:
                    break
                stack.insert(0, (immediate, n))
                immediate = parent
            else:
                v, n = stack.pop()
                p = Position(v, n, stack)
                positions.append(p)
        return positions

    # @+node:ekr.20090107113956.1: *5* c.vnode2position
    def vnode2position(self, v: VNode) -> Position | None:
        """
        Given a VNode v, construct a valid position p such that p.v = v.
        """
        c = self
        context = v.context  # v's commander.
        assert c == context
        stack: list[tuple[VNode, int]] = []
        while v.parents:
            parent = v.parents[0]
            if v in parent.children:
                n = parent.children.index(v)
            else:
                return None
            stack.insert(0, (v, n))
            v = parent
        # v.parents includes the hidden root node.
        if not stack:
            # a VNode not in the tree
            return None
        v, n = stack.pop()
        p = Position(v, n, stack)
        return p

    # @+node:ekr.20090130135126.1: *4* c.p property
    def __get_p(self) -> Position:
        c = self
        return c.currentPosition()

    def __set_p(self, p: Position) -> None:
        c = self
        c.setCurrentPosition(p)

    p = property(
        __get_p,
        __set_p,
        doc="commander current position property",
    )

    # @+node:ekr.20060906211747.1: *4* c.Setters
    # @+node:ekr.20040315032503: *5* c.appendStringToBody
    def appendStringToBody(self, p: Position, s: str) -> None:
        if s:
            p.b = p.b + g.toUnicode(s)

    # @+node:ekr.20031218072017.2984: *5* c.clearAllMarked
    def clearAllMarked(self) -> None:
        c = self
        for p in c.all_unique_positions(copy=False):
            p.v.clearMarked()

    # @+node:ekr.20031218072017.2985: *5* c.clearAllVisited
    def clearAllVisited(self) -> None:
        c = self
        for p in c.all_unique_positions(copy=False):
            p.v.clearVisited()
            p.v.clearWriteBit()

    # @+node:ekr.20191215044636.1: *5* c.clearChanged
    def clearChanged(self) -> None:
        """clear the marker that indicates that the .leo file has been changed."""
        c = self
        if not c.frame:
            return
        c.changed = False
        if c.loading:
            return  # don't update while loading.
        # Clear all dirty bits _before_ setting the caption.
        for v in c.all_unique_nodes():
            if v.statusBits & v.dirtyBit:  # Only clear if actually dirty
                v.clearDirty()
        c.changed = False
        # Do nothing for null frames.
        assert c.gui
        if c.gui.guiName() == 'nullGui':
            return
        if not c.frame.top:
            return
        if master := getattr(c.frame.top, 'leo_master', None):
            master.setChanged(c, changed=False)  # LeoTabbedTopLevel.setChanged.
        s = c.frame.getTitle()
        if len(s) > 2 and s[0:2] == "* ":
            c.frame.setTitle(s[2:])

    # @+node:ekr.20060906211138: *5* c.clearMarked
    def clearMarked(self, p: Position) -> None:
        c = self
        assert p.v
        p.v.clearMarked()
        g.doHook("clear-mark", c=c, p=p)

    # @+node:ekr.20040305223522: *5* c.setBodyString
    def setBodyString(self, p: Position, s: bytes | str) -> None:
        """
        This is equivalent to p.b = s.
        """
        c, v = self, p.v
        if not c or not v:
            return
        s = g.toUnicode(s)
        if c.p and p.v == c.p.v:
            w = c.frame.body.wrapper
            w.setAllText(s)
            v.setSelection(0, 0)
            c.recolor()

        # Keep the body text in the VNode up-to-date.
        if v.b != s:
            v.setBodyString(s)
            v.setSelection(0, 0)
            p.setDirty()
            if not c.isChanged():
                c.setChanged()

    # @+node:ekr.20031218072017.2989: *5* c.setChanged
    def setChanged(self, *, force: bool = False) -> None:
        """Set the marker that indicates that the .leo file has been changed."""
        c = self
        if not c.frame:
            return
        c.changed = True
        if c.loading and not force:
            return  # don't update while loading.
        # Do nothing for null frames.
        assert c.gui
        if c.gui.guiName() == 'nullGui':
            return
        if not c.frame.top:
            return
        if master := getattr(c.frame.top, 'leo_master', None):
            master.setChanged(c, changed=True)  # LeoTabbedTopLevel.setChanged.
        s = c.frame.getTitle()
        if len(s) > 2 and s[0] != '*':
            c.frame.setTitle("* " + s)

    # @+node:ekr.20040803140033.1: *5* c.setCurrentPosition
    _currentCount = 0

    def setCurrentPosition(self, p: Position) -> None:
        """
        Set the presently selected position. For internal use only.
        Client code should use c.selectPosition instead.
        """
        c = self
        if not p:
            g.trace('===== no p', g.callers())
            return
        if c.positionExists(p):
            if c._currentPosition and p == c._currentPosition:
                pass  # We have already made a copy.
            else:  # Make a copy _now_
                c._currentPosition = p.copy()
        else:
            message = f"Invalid position: {p!r} in {c.shortFileName()}\n{g.callers(10)=}"
            if g.unitTesting:
                raise ValueError(message)
            g.trace(message)
            c._currentPosition = c.rootPosition()

    # For compatibility with old scripts.

    setCurrentVnode = setCurrentPosition

    # @+node:ekr.20040305223225: *5* c.setHeadString
    def setHeadString(self, p: Position, s: str) -> None:
        """
        Set the p's headline and the corresponding tree widget to s.

        This is used in by unit tests to restore the outline.
        """
        c = self
        p.initHeadString(s)
        p.setDirty()
        # Change the actual tree widget so
        # A later call to c.endEditing or c.redraw will use s.
        c.frame.tree.setHeadline(p, s)

    # @+node:ekr.20060109164136: *5* c.setLog
    def setLog(self) -> None:
        c = self
        if c.exists:
            try:
                # c.frame or c.frame.log may not exist.
                g.app.setLog(c.frame.log)
            except AttributeError:
                pass

    # @+node:ekr.20060906211138.1: *5* c.setMarked (calls hook)
    def setMarked(self, p: Position) -> None:
        c = self
        p.setMarked()
        p.setDirty()  # Defensive programming.
        g.doHook("set-mark", c=c, p=p)

    # @+node:ekr.20040803140033.3: *5* c.setRootPosition (A do-nothing)
    def setRootPosition(self, unused_p: Position | None = None) -> None:
        """Set c._rootPosition."""
        # 2011/03/03: No longer used.

    # @+node:ekr.20060906131836: *5* c.setRootVnode (A do-nothing)
    def setRootVnode(self, v: VNode) -> None:
        pass
        # c = self
        # # 2011/02/25: c.setRootPosition needs no arguments.
        # c.setRootPosition()

    # @+node:ekr.20040311173238: *5* c.topPosition & c.setTopPosition
    def topPosition(self) -> Position | None:
        """Return the root position."""
        c = self
        if c._topPosition:
            return c._topPosition.copy()
        return None

    def setTopPosition(self, p: Position) -> None:
        """Set the root position."""
        c = self
        if p:
            c._topPosition = p.copy()
        else:
            c._topPosition = None

    # Define these for compatibility with old scripts...

    topVnode = topPosition
    setTopVnode = setTopPosition

    # @+node:ekr.20171124081419.1: *3* c.Check Outline...
    # @+node:ekr.20141024211256.22: *4* c.checkGnxs
    def checkGnxs(self) -> int:
        """
        Check the consistency of all gnx's.
        Reallocate gnx's for duplicates or empty gnx's.
        Return the number of errors found.
        """
        c = self
        # Keys are gnx's; values are lists of (id(v), v).
        vnode_d: dict[str, list[tuple[int, VNode]]] = {}
        ni = g.app.nodeIndices
        assert ni
        t1 = time.time()

        def new_gnx(v: VNode) -> None:
            """Set v.fileIndex."""
            v.fileIndex = ni.getNewIndex(v)

        count, gnx_errors = 0, 0
        for p in c.all_positions(copy=False):
            count += 1
            v = p.v
            gnx = v.fileIndex
            assert isinstance(gnx, str)  # PR #4773
            if gnx:  # gnx must be a string.
                aList = vnode_d.get(gnx, [])
                aList.append((id(v), v))
            else:
                gnx_errors += 1
                new_gnx(v)
                g.es_print(f"empty v.fileIndex: {v} new: {p.v.gnx!r}", color='red')
        for gnx in sorted(vnode_d.keys()):
            aList = list(vnode_d.get(gnx) or [])
            ids = [id_ for (id_, v) in aList]
            if len(ids) > 1:
                print('\nc.checkGnxs...')
                g.es_print(f"multiple vnodes with gnx: {gnx!r}", color='red')
                for id_, v in aList:
                    gnx_errors += 1
                    g.es_print(f"id(v): {id(v)} gnx: {v.fileIndex} {v.h}", color='red')
                    new_gnx(v)
        ok = not gnx_errors
        t2 = time.time()
        if not ok:
            g.es_print(
                f"check-outline ERROR! {c.shortFileName()} {count} nodes, {gnx_errors} gnx errors, ",
                color='red',
            )
        elif c.verbose_check_outline and not g.unitTesting:
            print(f"check-outline OK: {t2 - t1:4.2f} sec. {c.shortFileName()} {count} nodes")
        return gnx_errors

    # @+node:ekr.20150318131947.7: *4* c.checkLinks & helpers
    def checkLinks(self) -> int:
        """
        Check the consistency of all links in the outline.
        """
        # This is too slow a test to be run outside of unit tests.
        c = self
        count, errors = 0, 0
        for p in c.all_positions():
            count += 1
            if not c.checkThreadLinks(p):
                errors += 1
                break
            if not c.checkSiblings(p):
                errors += 1
                break
            if not c.checkParentAndChildren(p):
                errors += 1
                break
        return errors

    # @+node:ekr.20040314035615.2: *5* c.checkParentAndChildren
    def checkParentAndChildren(self, p: Position) -> bool:
        """Check consistency of parent and child data structures."""
        c = self

        assert p.v

        def _assert(condition: bool) -> bool:
            return g._assert(condition, show_callers=False)

        def dump(p: Position) -> None:
            if p and p.v:
                p.v.dump()
            elif p:
                print('<no p.v>')
            else:
                print('<no p>')

        if p.hasParent():
            n = p.childIndex()
            if not _assert(p == p.parent().moveToNthChild(n)):
                g.trace(f"p != parent().moveToNthChild({n})")
                dump(p)
                dump(p.parent())
                return False
        if p.level() > 0 and not _assert(bool(p.v.parents)):
            g.trace("no parents")
            dump(p)
            return False
        for child in p.children():
            if not c.checkParentAndChildren(child):
                return False
            if not _assert(p == child.parent()):
                g.trace("p != child.parent()")
                dump(p)
                dump(child.parent())
                return False
        if p.hasNext():
            if not _assert(p.next().parent() == p.parent()):
                g.trace("p.next().parent() != p.parent()")
                dump(p.next().parent())
                dump(p.parent())
                return False
        if p.hasBack():
            if not _assert(p.back().parent() == p.parent()):
                g.trace("p.back().parent() != parent()")
                dump(p.back().parent())
                dump(p.parent())
                return False
        # Check consistency of parent and children arrays.
        # Every nodes gets visited, so a strong test need only check consistency
        # between p and its parent, not between p and its children.
        parent_v = p._parentVnode()
        n = p.childIndex()
        if not _assert(parent_v.children[n] == p.v):
            g.trace("parent_v.children[n] != p.v")
            parent_v.dump()
            p.v.dump()
            return False
        return True

    # @+node:ekr.20040314035615.1: *5* c.checkSiblings
    def checkSiblings(self, p: Position) -> bool:
        """Check the consistency of next and back links."""
        back = p.back()
        next = p.next()
        if back:
            if not g._assert(p == back.next()):
                g.trace(
                    f"p!=p.back().next()\n"
                    f"     back: {back}\n"
                    f"back.next: {back.next()}"
                )  # fmt: skip
                return False
        if next:
            if not g._assert(p == next.back()):
                g.trace(
                    f"p!=p.next().back\n"
                    f"     next: {next}\n"
                    f"next.back: {next.back()}"
                )  # fmt: skip
                return False
        return True

    # @+node:ekr.20040314035615: *5* c.checkThreadLinks
    def checkThreadLinks(self, p: Position) -> bool:
        """Check consistency of threadNext & threadBack links."""
        threadBack = p.threadBack()
        threadNext = p.threadNext()
        if threadBack:
            if not g._assert(p == threadBack.threadNext()):
                g.trace("p!=p.threadBack().threadNext()")
                return False
        if threadNext:
            if not g._assert(p == threadNext.threadBack()):
                g.trace("p!=p.threadNext().threadBack()")
                return False
        return True

    # @+node:ekr.20230723031540.1: *5* c.checkVnodeLinks & helpers
    def checkVnodeLinks(self) -> int:
        """
        Check all vnode links.

        Attempt error recovery if recover_flag is True.
        Unit tests may set recover_flag to False for strict tests.

        Return the number of errors.
        """
        c = self

        # @+others  # Define helpers.
        # @+node:ekr.20230728005934.1: *6* find_errors
        def find_errors() -> tuple[list[tuple[VNode, VNode]], list[str], int]:
            """
            Scan all vnodes for erroneous parent/child pairs.

            Return (error_list, messages, n)
            """
            error_list: list[tuple[VNode, VNode]] = []
            messages: list[str] = []
            n = 0
            for parent_v in c.all_unique_nodes():  # Avoids recursion.
                for child_v in parent_v.children:
                    children_n = parent_v.children.count(child_v)
                    parents_n = child_v.parents.count(parent_v)
                    if children_n != parents_n:
                        error_list.append((parent_v, child_v))
                        messages.append(
                            'Mismatch between parent.children and child.parents\n'
                            f"parent: {parent_v.h:30} count(parent.children) = {children_n}\n"
                            f" child: {child_v.h:30} count(child.parents = {parents_n}"
                        )
                        n += 1
            return error_list, messages, n

        # @+node:ekr.20230728010156.1: *6* fix_errors
        def fix_errors(error_list: list[tuple[VNode, VNode]]) -> None:
            """Fix all erroneous nodes by adding/deleting entries from v.parents."""
            for parent_v, child_v in error_list:
                children_n = parent_v.children.count(child_v)
                parents_n = child_v.parents.count(parent_v)
                if parents_n == children_n:
                    pass  # Already fixed.
                elif parents_n < children_n:
                    while parents_n < children_n:
                        # Safe.
                        child_v.parents.append(parent_v)
                        parents_n += 1
                else:
                    while children_n < parents_n:
                        if child_v.parents:
                            # Safe.
                            child_v.parents.remove(parent_v)
                            children_n += 1
                        else:  # pragma: no cover
                            # This could delete the child.
                            parent_v.children.remove(child_v)
                            parents_n += 1

        # @+node:ekr.20230728010753.1: *6* undelete_nodes
        def undelete_nodes(error_list: list[tuple[VNode, VNode]]) -> None:
            """Restore a parent link to any node that would otherwise be deleted."""
            seen: list[VNode] = []
            for parent_v, child_v in error_list:
                if not child_v.parents and child_v not in seen:  # pragma: no cover
                    # Add child_v to *one* parent.
                    seen.append(child_v)
                    parent_v.children.append(child_v)
                    child_v.parents.append(parent_v)

        # @+node:ekr.20230728011151.1: *6* recheck
        def recheck() -> tuple[list[tuple[VNode, VNode]], list[str], int]:
            """
            Rescan all vnodes to ensure that no errors remain.

            Return (error_list, messages, n).
            """
            error_list: list[tuple[VNode, VNode]] = []
            messages: list[str] = []
            n = 0
            for parent_v in c.all_unique_nodes():  # Avoids recursion.
                for child_v in parent_v.children:
                    children_n = parent_v.children.count(child_v)
                    parents_n = child_v.parents.count(parent_v)
                    if children_n != parents_n:  # pragma: no cover
                        error_list.append((parent_v, child_v))
                        messages.append(
                            'Error recovery failed!\n'
                            f"parent: {parent_v.h:30} count(parent.children) = {children_n}\n"
                            f" child: {child_v.h:30} count(child.parents = {parents_n}"
                        )
                        n += 1
            return error_list, messages, n

        # @-others

        # For unit testing.
        strict = 'test:strict' in g.app.debug
        verbose = any(
            z in g.app.debug for z in ('test:verbose', 'gnx', 'shutdown', 'startup', 'verbose')
        )
        error_list, messages, n = find_errors()
        if n == 0:
            return 0
        if verbose:  # pragma: no cover
            print('\n')
            g.trace(f"{len(messages)} link error{g.plural(len(messages))}:\n")
            print('\n'.join(messages) + '\n')
        if strict:  # pragma: no cover
            return n
        old_n = n
        fix_errors(error_list)
        undelete_nodes(error_list)
        error_list, messages, n = recheck()
        if n:  # pragma: no cover
            # Report the *failure* to fix links!
            print('\n'.join(messages))
        elif verbose:  # pragma: no cover
            g.trace(f"Fixed {old_n} link error{g.plural(old_n)}")
        return n

    # @+node:ekr.20031218072017.1760: *4* c.checkMoveWithParentWithWarning & c.checkDrag
    # @+node:ekr.20070910105044: *5* c.checkMoveWithParentWithWarning
    def checkMoveWithParentWithWarning(
        self,
        root: Position,
        parent: Position,
        warningFlag: bool,
    ) -> bool:
        """
        Return False if root or any of root's descendants is a clone of parent
        or any of parents ancestors.
        """
        c = self
        message = "Illegal move or drag: no clone may contain a clone of itself"
        clonedVnodes = {}
        for ancestor in parent.self_and_parents(copy=False):
            if ancestor.isCloned():
                v = ancestor.v
                clonedVnodes[v] = v
        if not clonedVnodes:
            return True
        for p in root.self_and_subtree(copy=False):  # pragma: no cover
            if p.isCloned() and clonedVnodes.get(p.v):
                if not g.unitTesting and warningFlag:
                    c.alert(message)
                return False
        return True

    # @+node:ekr.20070910105044.1: *5* c.checkDrag
    def checkDrag(self, root: Position, target: Position) -> bool:
        """Return False if target is any descendant of root."""
        c = self
        message = "Can not drag a node into its descendant tree."
        for z in root.subtree():
            if z == target:
                if not g.unitTesting:
                    c.alert(message)
                return False
        return True

    # @+node:ekr.20031218072017.2072: *4* c.checkOutline
    def checkOutline(self) -> int:
        """
        Check for errors in the outline.
        Return the number of errors.
        """
        c, errors = self, 0
        for f in (c.checkVnodeLinks, c.checkGnxs):
            errors += f()
        return errors

    # @+node:ekr.20031218072017.1765: *4* c.validateOutline (compatibility only)
    # Makes sure all nodes are valid.

    def validateOutline(
        self,
        event: Any = None,  # not used.
    ) -> bool:
        """
        A legacy outline checker, retained only for compatibility.

        Not used in Leo's core or unit tests.
        """
        c = self
        return c.checkOutline() == 0

    # @+node:ekr.20240715040734.1: *4* c.checkOutlineXML
    def checkOutlineXML(self, dump: bool = True) -> bool:
        """Validate outline's xml."""
        c = self
        # #1510: https://en.wikipedia.org/wiki/Valid_characters_in_XML.
        translate_dict = {z: None for z in range(20) if chr(z) not in '\t\r\n'}
        xml_contents = c.fileCommands.outline_to_xml_string()
        table = xml_contents.maketrans(translate_dict)
        translated_contents = xml_contents.translate(table)
        try:
            xroot = ElementTree.fromstring(translated_contents)
            assert xroot
            return True
        except Exception:
            g.es_print('The outline is invalid!', color='red')
            g.es_exception()
            if dump:
                # Write the invalid ouitline to the corresponding leo.txt file.
                filename = os.path.normpath(
                    os.path.expanduser(f"~/.leo/BAD-{c.shortFileName()}.txt")
                )
                try:
                    with open(filename, 'bw') as f:
                        for s in g.splitLines(translated_contents):  # noqa
                            f.write(g.toEncodedString(s, reportErrors=True))
                    g.es_print('')
                    g.es_print(f"Wrote {filename}")
                    g.es_print('')
                except Exception:
                    g.es_print(f"Exception writing {filename}")
                    g.es_exception()
            return False

    # @+node:ekr.20040723094220: *3* c.Check Python code
    # This code is no longer used by any Leo command,
    # but it will be retained for use of scripts.
    # @+node:ekr.20040723094220.1: *4* c.checkAllPythonCode
    def checkAllPythonCode(
        self, event: LeoKeyEvent | None = None, ignoreAtIgnore: bool = True
    ) -> str:
        """Check all nodes in the selected tree for syntax and tab errors."""
        c = self
        count = 0
        result = "ok"
        for p in c.all_unique_positions():
            count += 1
            if not g.unitTesting:
                # @+<< print dots >>
                # @+node:ekr.20040723094220.2: *5* << print dots >>
                if count % 100 == 0:
                    g.es('', '.', newline=False)
                if count % 2000 == 0:
                    g.enl()
                # @-<< print dots >>
            if c.getLanguage(p) == "python":
                if not g.scanForAtSettings(p) and (
                    not ignoreAtIgnore or not g.scanForAtIgnore(c, p)
                ):
                    try:
                        c.checkPythonNode(p)
                    except (SyntaxError, tokenize.TokenError, tabnanny.NannyNag):
                        result = "error"  # Continue to check.
                    except Exception:
                        return "surprise"  # abort
                    if result != 'ok':
                        g.pr(f"Syntax error in {p.h}")
                        return result  # End the unit test: it has failed.
        if not g.unitTesting:
            g.blue("check complete")
        return result

    # @+node:ekr.20040723094220.3: *4* c.checkPythonCode
    def checkPythonCode(
        self,
        event: Any = None,  # not used.
        ignoreAtIgnore: bool = True,
        checkOnSave: bool = False,
    ) -> str:
        """Check the selected tree for syntax and tab errors."""
        c = self
        count = 0
        result = "ok"
        if not g.unitTesting:
            g.es("checking Python code   ")
        for p in c.p.self_and_subtree():
            count += 1
            if not g.unitTesting and not checkOnSave:
                # @+<< print dots >>
                # @+node:ekr.20040723094220.4: *5* << print dots >>
                if count % 100 == 0:
                    g.es('', '.', newline=False)
                if count % 2000 == 0:
                    g.enl()
                # @-<< print dots >>
            if c.getLanguage(p) == "python":
                if not ignoreAtIgnore or not g.scanForAtIgnore(c, p):
                    try:
                        c.checkPythonNode(p)
                    except (SyntaxError, tokenize.TokenError, tabnanny.NannyNag):
                        result = "error"  # Continue to check.
                    except Exception:
                        return "surprise"  # abort
        if not g.unitTesting:
            g.blue("check complete")
        # We _can_ return a result for unit tests because we aren't using doCommand.
        return result

    # @+node:ekr.20040723094220.5: *4* c.checkPythonNode
    def checkPythonNode(self, p: Position) -> None:
        c, h = self, p.h
        # Call getScript to ignore directives and section references.
        body = g.getScript(c, p.copy())
        if not body:
            return
        try:
            fn = f"<node: {p.h}>"
            compile(body + '\n', fn, 'exec')
            c.tabNannyNode(p, h, body)
        except SyntaxError:
            if g.unitTesting:
                raise
            g.warning(f"Syntax error in: {h}")
            g.es_exception()
        except Exception:
            g.es_print('unexpected exception')
            g.es_exception()
            raise

    # @+node:ekr.20040723094220.6: *4* c.tabNannyNode
    # This code is based on tabnanny.check.

    def tabNannyNode(self, p: Position, headline: str, body: str) -> None:
        """Check indentation using tabnanny."""
        try:
            readline = g.ReadLinesClass(body).next
            tabnanny.process_tokens(tokenize.generate_tokens(readline))
        except IndentationError:
            if g.unitTesting:
                raise
            junk1, msg, junk2 = sys.exc_info()
            g.warning("IndentationError in", headline)
            g.es('', msg)
        except tokenize.TokenError:
            if g.unitTesting:
                raise
            junk1, msg, junk2 = sys.exc_info()
            g.warning("TokenError in", headline)
            g.es('', msg)
        except tabnanny.NannyNag:
            if g.unitTesting:
                raise
            junk1, nag, junk2 = sys.exc_info()
            assert nag
            badline = nag.get_lineno()
            line = nag.get_line()
            message = nag.get_msg()
            g.warning("indentation error in", headline, "line", badline)
            g.es(message)
            line2 = repr(str(line))[1:-1]
            g.es("offending line:\n", line2)
        except Exception:
            g.trace("unexpected exception")
            g.es_exception()
            raise

    # @+node:ekr.20171123200644.1: *3* c.Convenience methods
    # @+node:ekr.20230402232100.1: *4* c.fullPath
    def fullPath(self, p: Position) -> str:
        """
        Return the absolute path in effect at p.

        Return the path to an external file if p is an @<file> node.
        Otherwise the return the path to the enclosing directory.
        """
        c = self
        path = c.getPath(p)
        return g.finalize_join(path, p.anyAtFileNodeName())

    # @+node:ekr.20250616161500.1: *4* c.relativeDirectory
    def relativeDirectory(self, path: str) -> str:
        """Return the path relative to this outline, or the full, absolute path."""
        c = self
        baseDir = os.path.dirname(c.fileName())
        return g.relativeDirectory(baseDir, path)

    # @+node:ekr.20171123135625.39: *4* c.getTime
    def getTime(self, body: bool = True) -> str:
        c = self
        default_format = "%m/%d/%Y %H:%M:%S"  # E.g., 1/30/2003 8:31:55
        # Try to get the format string from settings.
        if body:
            format = c.config.getString("body-time-format-string")
            gmt = c.config.getBool("body-gmt-time")
        else:
            format = c.config.getString("headline-time-format-string")
            gmt = c.config.getBool("headline-gmt-time")
        if not format:
            format = default_format
        try:
            # import time
            if gmt:
                s = time.strftime(format, time.gmtime())
            else:
                s = time.strftime(format, time.localtime())
        except (ImportError, NameError):
            g.warning("time.strftime not available on this platform")
            return ""
        except Exception:
            g.es_exception()  # Probably a bad format string in leoSettings.leo.
            s = time.strftime(default_format, time.gmtime())
        return s

    # @+node:ekr.20171123135625.10: *4* c.goToLineNumber & goToScriptLineNumber
    def goToLineNumber(self, n: int) -> None:
        """
        Go to line n (zero-based) of a script.
        A convenience method called from g.handleScriptException.
        """
        c = self
        c.gotoCommands.find_file_line(n)

    def goToScriptLineNumber(self, n: int, p: Position) -> None:
        """
        Go to line n (zero-based) of a script.
        A convenience method called from g.handleScriptException.
        """
        c = self
        c.gotoCommands.find_script_line(n, p)

    # @+node:ekr.20090103070824.9: *4* c.setFileTimeStamp
    def setFileTimeStamp(self, fn: str) -> None:
        """Update the timestamp for fn.."""
        # c = self
        if g.app.externalFilesController:
            g.app.externalFilesController.set_time(fn)

    # @+node:ekr.20031218072017.3000: *4* c.updateSyntaxColorer
    def updateSyntaxColorer(self, p: Position) -> None:
        self.frame.body.updateSyntaxColorer(p)

    # @+node:ekr.20180503110307.1: *4* c.interactive*
    # @+node:ekr.20180504075937.1: *5* c.interactive
    def interactive(self, callback: Callable, event: LeoKeyEvent | None, prompts: Sequence) -> None:
        # @+<< c.interactive docstring >>
        # @+node:ekr.20180503131222.1: *6* << c.interactive docstring >>
        """
        c.interactive: Prompt for up to three arguments from the minibuffer.

        The number of prompts determines the number of arguments.

        Use the @command decorator to define commands.  Examples:

            @g.command('i3')
            def i3_command(event: LeoKeyEvent | None = None) -> None:
                c = event.get('c') if event else None
                if not c: return

                def callback(args: Any, c: Cmdr, event: LeoKeyEvent | None = None) -> None:
                    g.trace(args)
                    c.bodyWantsFocus()

                c.interactive(callback, event,
                    prompts=['Arg1: ', ' Arg2: ', ' Arg3: '])
        """
        # @-<< c.interactive docstring >>

        # This pathetic code should be generalized,
        # but it's not as easy as one might imagine.
        c = self
        d = {
            1: c.interactive1,
            2: c.interactive2,
            3: c.interactive3,
        }
        if f := d.get(len(prompts)):
            f(callback, event, prompts)
        else:
            g.trace('At most 3 arguments are supported.')

    # @+node:ekr.20180503111213.1: *5* c.interactive1
    def interactive1(
        self,
        callback: Callable,
        event: LeoKeyEvent | None,  # Used in the callback.
        prompts: Sequence,
    ) -> None:
        c, k = self, self.k
        prompt = prompts[0]

        def state1(event: LeoKeyEvent | None = None) -> None:
            callback(args=[k.arg], c=c, event=event)
            k.clearState()
            k.resetLabel()
            k.showStateAndMode()

        k.setLabelBlue(prompt)
        k.get1Arg(event, handler=state1)

    # @+node:ekr.20180503111249.1: *5* c.interactive2
    def interactive2(
        self,
        callback: Callable,  # Used in the callback.
        event: LeoKeyEvent | None,
        prompts: Sequence,
    ) -> None:
        c, k = self, self.k
        d: dict[str, str] = {}
        prompt1, prompt2 = prompts

        def state1(event: LeoKeyEvent | None = None) -> None:
            d['arg1'] = k.arg
            k.extendLabel(prompt2, select=False, protect=True)
            k.getNextArg(handler=state2)

        def state2(event: LeoKeyEvent | None = None) -> None:
            callback(args=[d.get('arg1'), k.arg], c=c, event=event)
            k.clearState()
            k.resetLabel()
            k.showStateAndMode()

        k.setLabelBlue(prompt1)
        k.get1Arg(event, handler=state1)

    # @+node:ekr.20180503111249.2: *5* c.interactive3
    def interactive3(
        self,
        callback: Callable,
        event: LeoKeyEvent | None,  # Used in the callback.
        prompts: Sequence,
    ) -> None:
        c = self
        d: dict[str, str] = {}
        k = self.k
        prompt1, prompt2, prompt3 = prompts

        def state1(event: LeoKeyEvent | None = None) -> None:
            d['arg1'] = k.arg
            k.extendLabel(prompt2, select=False, protect=True)
            k.getNextArg(handler=state2)

        def state2(event: LeoKeyEvent | None = None) -> None:
            d['arg2'] = k.arg
            k.extendLabel(prompt3, select=False, protect=True)
            k.get1Arg(event, handler=state3)  # Restart.

        def state3(event: LeoKeyEvent | None = None) -> None:
            args = [d.get('arg1'), d.get('arg2'), k.arg]
            callback(args=args, c=c, event=event)
            k.clearState()
            k.resetLabel()
            k.showStateAndMode()

        k.setLabelBlue(prompt1)
        k.get1Arg(event, handler=state1)

    # @+node:ekr.20080901124540.1: *3* c.Directive scanning
    # These are all new in Leo 4.5.1.
    # @+node:ekr.20171123135625.33: *4* c.getLanguageAtCursor
    def getLanguageAtCursor(self, p: Position, language: str) -> str:
        """
        Return the language in effect at the present insert point.
        Use the language argument as a default if no @language directive seen.
        """
        c = self
        tag = '@language'
        w = c.frame.body.wrapper
        ins = w.getInsertPoint()
        n = 0
        for s in g.splitLines(p.b):
            if g.match_word(s, 0, tag):
                i = g.skip_ws(s, len(tag))
                j = g.skip_id(s, i)
                language = s[i:j]
            if n <= ins < n + len(s):
                break
            else:
                n += len(s)
        return language

    # @+node:ekr.20081006100835.1: *4* c.getNodePath (deprecated)
    def getNodePath(self, p: Position) -> str:
        """Return the path in effect at node p."""
        g.deprecated()
        c = self
        return c.getPath(p)

    # @+node:ekr.20171123135625.32: *4* c.hasAmbiguousLanguage
    def hasAmbiguousLanguage(self, p: Position) -> int:
        """Return True if p.b contains different @language directives."""
        # c = self
        languages, tag = set(), '@language'
        for s in g.splitLines(p.b):
            if g.match_word(s, 0, tag):
                i = g.skip_ws(s, len(tag))
                j = g.skip_id(s, i)
                word = s[i:j]
                languages.add(word)
        return len(list(languages)) > 1

    # @+node:ekr.20250404014922.1: *4* --- c: Legacy scanners (deprecated)
    # @+node:ekr.20080827175609.39: *5* c.scanAllDirectives (deprecated)
    def scanAllDirectives(self, p: Position) -> dict[str, Value]:
        """
        Scan p and ancestors for directives.

        Returns a dict containing the results, including defaults.
        """
        g.deprecated()
        c = self
        p = p or c.p
        # Defaults...
        default_language = c.getLanguage(p) or c.target_language or 'python'
        default_delims = g.set_delims_from_language(default_language)
        wrap = c.config.getBool("body-pane-wraps")
        table: tuple = (
            ('encoding',    '',           g.scanAtEncodingDirectives),
            ('lang-dict',   {},           g.scanAtCommentAndAtLanguageDirectives),
            ('lineending',  '',           g.scanAtLineendingDirectives),
            ('pagewidth',   c.page_width, g.scanAtPagewidthDirectives),
            ('path',        '',           c.scanAtPathDirectives),
            ('tabwidth',    c.tab_width,  g.scanAtTabwidthDirectives),
            ('wrap',        wrap,         g.scanAtWrapDirectives),
        )  # fmt: skip

        # Set d by scanning all directives.
        aList = g.get_directives_dict_list(p)
        d: dict[str, Value] = {}
        for key, default, func in table:
            val = func(aList)
            d[key] = default if val is None else val

        # Post process: do *not* set commander ivars.
        lang_dict = d.get('lang-dict')
        assert lang_dict
        d = {
            "delims":       lang_dict.get('delims') or default_delims,
            "comment":      lang_dict.get('comment'),  # Leo 6.4: New.
            "encoding":     d.get('encoding'),
            # Note: at.scanAllDirectives does not use the defaults for "language".
            "language":     lang_dict.get('language') or default_language,
            "lang-dict":    lang_dict,  # Leo 6.4: New.
            "lineending":   d.get('lineending'),
            "pagewidth":    d.get('pagewidth'),
            "path":         d.get('path'),
            "tabwidth":     d.get('tabwidth'),
            "wrap":         d.get('wrap'),
        }  # fmt: skip
        return d

    # @+node:ekr.20080828103146.15: *5* c.scanAtPathDirectives (deprecated)
    def scanAtPathDirectives(self, aList: list) -> str:
        """
        Scan aList (created by g.get_directives_dict_list) for @path directives.

        Return a reasonable default if no @path directive is found.
        """
        g.deprecated()
        c = self
        c.scanAtPathDirectivesCount += 1  # An important statistic.
        if c.fileName():
            absbase = g.os_path_dirname(c.fileName())
        else:
            absbase = os.getcwd()

        # Look for @path directives.
        paths = []
        for d in aList:
            # Look for @path directives.
            if path := d.get('path'):
                # Convert "path" or <path> to path.
                if path := g.stripPathCruft(path):
                    paths.append(path)

        # Add absbase and reverse the list.
        paths.append(absbase)
        paths.reverse()

        # Compute the full, effective, absolute path.
        path = g.finalize_join(*paths)
        return path

    # @+node:ekr.20171123201514.1: *3* c.Executing commands & scripts
    # @+node:ekr.20110605040658.17005: *4* c.check_event
    def check_event(self, event: LeoKeyEvent | None = None) -> None:
        """Check an event object."""
        from leo.core import leoGui

        if not event:
            return
        if not isinstance(event, leoGui.LeoKeyEvent):
            if g.app.gui.guiName() not in ('browser', 'console', 'curses'):  # #1839.
                g.trace(f"not leo event: {event!r}, callers: {g.callers(8)}")

    # @+node:ekr.20031218072017.2817: *4* c.doCommand
    def doCommand(
        self,
        command_func: Callable,
        command_name: str,
        event: LeoKeyEvent | None = None,
    ) -> Value:
        """
        Execute the given command function, invoking hooks and catching exceptions.

        The code assumes that the "command1" hook has completely handled the
        command func if g.doHook("command1") returns False. This provides a
        simple mechanism for overriding commands.
        """
        c, p = self, self.p
        c.setLog()
        self.command_count += 1
        # New in Leo 6.2. Set command_function and command_name ivars.
        self.command_function = command_func
        self.command_name = command_name
        # The presence of this message disables all commands.
        if c.disableCommandsMessage:
            g.blue(c.disableCommandsMessage)
            return None
        if c.exists and c.inCommand and not g.unitTesting:
            g.app.commandInterruptFlag = True  # For sc.make_slide_show_command.
            # 1912: This message is annoying and unhelpful.
            # g.error('ignoring command: already executing a command.')
            return None
        g.app.commandInterruptFlag = False
        # #2256: Update the list of recent commands.
        if len(c.recent_commands_list) > 99:
            c.recent_commands_list.pop()
        c.recent_commands_list.insert(0, command_name)
        return_value = None
        if not g.doHook("command1", c=c, p=p, label=command_name):
            try:
                c.inCommand = True
                try:
                    return_value = command_func(event)
                except Exception:
                    g.es_exception()
                    return_value = None
                if c and c.exists:  # Be careful: the command could destroy c.
                    c.inCommand = False
            except Exception:
                c.inCommand = False
                if g.unitTesting:
                    raise
                g.es_print("exception executing command")
                g.es_exception()
            if c and c.exists:
                if c.requestCloseWindow:
                    c.requestCloseWindow = False
                    g.app.closeLeoWindow(c.frame)
                else:
                    c.outerUpdate()
        # Be careful: the command could destroy c.
        if c and c.exists:
            p = c.p
            g.doHook("command2", c=c, p=p, label=command_name)
        return return_value

    # @+node:ekr.20200522075411.1: *4* c.doCommandByName
    def doCommandByName(self, command_name: str, event: LeoKeyEvent | None = None) -> Value:
        """
        Execute one command, given the name of the command.

        The caller must do any required keystroke-only tasks.

        Return the result, if any, of the command.
        """
        c = self
        if not event:
            # Create a default key event.
            event = g.app.gui.create_key_event(c)

        # Get the command's function.
        command_func = c.commandsDict.get(command_name.replace('&', ''))
        if not command_func:
            message = f"no command function for {command_name!r}"
            if g.unitTesting or g.app.inBridge:
                raise AttributeError(message)
            g.es_print(message, color='red')
            g.trace(g.callers())
            return None
        # Invoke the function.
        val = c.doCommand(command_func, command_name, event)
        if c.exists:
            c.frame.updateStatusLine()
        return val

    # @+node:ekr.20200526074132.1: *4* c.executeMinibufferCommand
    def executeMinibufferCommand(self, commandName: str) -> Value:
        """Call c.doCommandByName, creating the required event."""
        c = self
        event = g.app.gui.create_key_event(c)
        return c.doCommandByName(commandName, event)

    # @+node:ekr.20210305133229.1: *4* c.general_script_helper & helpers
    def general_script_helper(
        self,
        command: str,
        ext: str,
        language: str,
        root: Position,
        directory: str = '',
        regex: str = '',
    ) -> None:
        """
        The official helper for the execute-general-script command.

        c:          The Commander of the outline.
        command:    The os command to execute the script.
        directory:  Optional: Change to this directory before executing command.
        ext:        The file extension for the temporary file.
        language:   The language name.
        regex:      Optional regular expression describing error messages.
                    If present, group(1) should evaluate to a line number.
                    May be a compiled regex expression or a string.
        root:       The root of the tree containing the script,
                    The script may contain section references and @others.

        Other features:

        - Create a temporary external file if `not root.isAnyAtFileNode()`.
        - Compute the final command as follows.
          1. If command contains <FILE>, replace <FILE> with the full path.
          2. If command contains <NO-FILE>, just remove <NO-FILE>.
             This allows, for example, `go run .` to work as expected.
          3. Append the full path to the command.
        """
        c, log = self, self.frame.log

        # @+others  # Define helper functions
        # @+node:ekr.20210529142153.1: *5* function: put_line (c.general_script_helper)
        def put_line(s: str) -> None:
            """
            Put the line, creating a clickable link if the regex matches.
            """
            if not regex:
                g.es_print(s)
                return
            # Get the line number.
            m = regex.match(s)
            if not m:
                g.es_print(s)
                return
            # If present, the regex should define two groups.
            try:
                s1 = m.group(1)
                s2 = m.group(2)
            except IndexError:
                g.es_print(f"Regex {regex.pattern()} must define two groups")
                return
            if s1.isdigit():
                n = int(s1)
                fn = s2
            elif s2.isdigit():
                n = int(s2)
                fn = s1
            else:
                # No line number.
                g.es_print(s)
                return
            s = s.replace(root_path, root.h)
            # Print to the console.
            print(s)
            # Find the node and offset corresponding to line n.
            p, n2 = find_line(fn, n)
            # Create the link.
            if unl := p.get_UNL():
                log.put(s + '\n', nodeLink=f"{unl}::{n2}")  # local line.
            else:
                log.put(s + '\n')

        # @+node:ekr.20210529164957.1: *5* function: find_line (c.general_script_helper)
        def find_line(path: str, n: int) -> tuple[Position, int]:
            """
            Return the node corresponding to line n of external file given by path.
            """
            if path == root_path:
                p, offset = c.gotoCommands.find_file_line(n, root)
            else:
                # Find an @<file> node with the given path.
                for p in c.all_positions():
                    if p.isAnyAtFileNode():
                        norm_path = os.path.normpath(c.fullPath(p))
                        if path == norm_path:
                            p, offset = c.gotoCommands.find_file_line(n, p)
                            break
            return (p, offset) if p else (root, n)

        # @-others
        # Compile and check the regex.
        if regex:
            if isinstance(regex, str):
                try:
                    re.compile(regex)
                except Exception:
                    g.trace(f"Bad regex: {regex!s}")
                    return
        # Get the script.
        script = g.getScript(
            c,
            root,
            useSelectedText=False,
            forcePythonSentinels=False,  # language=='python',
            useSentinels=True,
        )
        # Create a temp file if root is not an @<file> node.
        if use_temp := not root.isAnyAtFileNode():
            fd, root_path = tempfile.mkstemp(suffix=ext, prefix="")
            with os.fdopen(fd, 'w', encoding='utf-8') as f:
                f.write(script)
        else:
            root_path = c.fullPath(root)
        # Compute the final command.
        if '<FILE>' in command:
            final_command = command.replace('<FILE>', root_path)
        elif '<NO-FILE>' in command:
            final_command = command.replace('<NO-FILE>', '').replace(root_path, '')
        else:
            final_command = f"{command} {root_path}"
        # Change directory.
        old_dir = os.path.abspath(os.path.curdir)
        if not directory:
            directory = os.path.dirname(root_path)
        os.chdir(directory)
        # Execute the final command.
        try:
            proc = subprocess.Popen(
                final_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
            )
            out, err = proc.communicate()
            for s in g.splitLines(g.toUnicode(out)):
                print(s.rstrip())
            print('')
            for s in g.splitLines(g.toUnicode(err)):
                put_line(s.rstrip())
        finally:
            if use_temp:
                os.remove(root_path)
            os.chdir(old_dir)

    # @+node:ekr.20200523135601.1: *4* c.insertCharFromEvent
    def insertCharFromEvent(self, event: LeoKeyEvent) -> None:
        """
        This method is an ugly hack, called by k.masterKeyHandler and other places.

        Handle the character given by event.stroke, ignoring various special keys.
        """
        trace = all(z in g.app.debug for z in ('keys', 'verbose'))
        c, k, w = self, self.k, event.w
        name = c.widget_name(w)
        stroke = event.stroke
        if trace:
            g.trace('stroke', stroke, 'plain:', k.isPlainKey(stroke), 'widget', name)
        if not stroke:
            return
        # #1448: Very late special cases.
        if stroke.isNumPadKey() and k.state.kind == 'getArg':
            stroke.removeNumPadModifier()
            k.getArg(event, stroke=stroke)
            return
        # Handle all unbound characters in command mode.
        if k.unboundKeyAction == 'command':
            w = g.app.gui.get_focus(c)
            if w and g.app.gui.widget_name(w).lower().startswith('canvas'):
                c.onCanvasKey(event)
            return
        # Ignore unbound F-keys.
        if stroke.isFKey():
            return
        # Ignore unbound Alt/Ctrl keys.
        if stroke.isAltCtrl():
            if not k.enable_alt_ctrl_bindings:
                return
            if k.ignore_unbound_non_ascii_keys:
                return
        if stroke.isPlainNumPad():  # #868
            stroke.removeNumPadModifier()
            event.stroke = stroke
        if stroke.isNumPadKey():  # #868
            return
        # Ignore unbound non-ascii character.
        if k.ignore_unbound_non_ascii_keys and not stroke.isPlainKey():
            return
        # Never insert escape or insert characters.
        if 'Escape' in stroke.s or 'Insert' in stroke.s:
            return
        # Handle events in the body pane.
        if name.startswith('body'):
            action = k.unboundKeyAction
            if action in ('insert', 'overwrite'):
                c.editCommands.selfInsertCommand(event, action=action)
                c.frame.updateStatusLine()
            return
        # Handle events in headlines.
        if name.startswith('head'):
            c.frame.tree.onHeadlineKey(event)
            return
        # Handle events in the background tree (not headlines).
        if name.startswith('canvas'):
            if event.char:
                k.searchTree(event.char)
            # Not exactly right, but it seems to be good enough.
            elif not stroke:
                c.onCanvasKey(event)
            return
        if not g.app.gui.isTextWrapper(w):
            return
        # Send the event to the text widget, not the LeoLog instance.
        i = w.getInsertPoint()
        s = stroke.toGuiChar()
        w.insert(i, s)

    # @+node:ekr.20131016084446.16724: *4* c.setComplexCommand
    def setComplexCommand(self, commandName: str) -> None:
        """Make commandName the command to be executed by repeat-complex-command."""
        c = self
        c.k.mb_history.insert(0, commandName)

    # @+node:bobjack.20080509080123.2: *4* c.universalCallback & minibufferCallback
    def universalCallback(self, source_c: Commands, function: Callable) -> Callable:
        """Create a universal command callback.

        Create and return a callback that wraps a function with an rClick
        signature in a callback which adapts standard minibuffer command
        callbacks to a compatible format.

        This also serves to allow rClick callback functions to handle
        minibuffer commands from sources other than rClick menus so allowing
        a single function to handle calls from all sources.

        A function wrapped in this wrapper can handle rclick generator
        and invocation commands and commands typed in the minibuffer.

        It will also be able to handle commands from the minibuffer even
        if rclick is not installed.
        """

        def minibufferCallback(event: LeoKeyEvent, function: Callable = function) -> None:
            # Avoid a pylint complaint.
            if hasattr(self, 'theContextMenuController'):
                cm = self.theContextMenuController
                keywords = cm.mb_keywords
            else:
                cm = keywords = None
            if not keywords:
                # If rClick is not loaded or no keywords dict was provided
                #  then the command must have been issued in a minibuffer
                #  context.
                keywords = {'c': self, 'rc_phase': 'minibuffer'}
            keywords['mb_event'] = event
            retval = None
            try:
                retval = function(keywords)
            finally:
                if cm:
                    # Even if there is an error:
                    #   clear mb_keywords prior to next command and
                    #   ensure mb_retval from last command is wiped
                    cm.mb_keywords = None
                    cm.mb_retval = retval

        minibufferCallback.__doc__ = function.__doc__  # For g.getDocStringForFunction
        minibufferCallback.source_c = source_c  # For GetArgs.command_source
        return minibufferCallback

    # fix bobjack's spelling error.
    universallCallback = universalCallback

    # @+node:ekr.20070115135502: *4* c.writeScriptFile
    def writeScriptFile(self, script: str) -> str:
        # Get the path to the file.
        c = self
        if path := c.config.getString('script-file-path'):
            isAbsPath = os.path.isabs(path)
            driveSpec, path = os.path.splitdrive(path)
            parts = path.split('/')
            # xxx bad idea, loadDir is often read only!
            path = g.app.loadDir
            if isAbsPath:
                # make the first element absolute
                parts[0] = driveSpec + os.sep + parts[0]
            allParts = [path] + parts
            path = g.finalize_join(*allParts)
        else:
            path = g.finalize_join(g.app.homeLeoDir, 'scriptFile.py')

        # Write the file.
        try:
            # Retain old modification time to avoid pdb problems.
            if os.path.exists(path):
                mod_time = os.path.getmtime(path)
                with open(path, encoding='utf-8', mode='w+') as f:
                    f.write(script)
                os.utime(path, (mod_time, mod_time))
            with open(path, encoding='utf-8', mode='w+') as f:
                f.write(script)
        except Exception:
            g.es_exception()
            g.es(f"Failed to write script to {path}")
            path = None
        return path

    # @+node:ekr.20190921130036.1: *3* c.expand_path_expression
    def expand_path_expression(self, s: str) -> str:
        """
        Apply Python's *standard* os.path tools to s:

        - os.path.expanduser: https://docs.python.org/3/library/os.path.html#os.path.expanduser
        - os.path.expandvars: https://docs.python.org/3/library/os.path.html#os.path.expandvars

        Do *not* call os.path.abspath, os.path.normpath, or g.os_path_normslashes.
        """
        if not s:
            return ''
        path = g.toUnicode(s)
        path = os.path.expanduser(path)
        path = os.path.expandvars(path)
        return path

    # @+node:ekr.20171124101444.1: *3* c.File
    # @+node:ekr.20200305104646.1: *4* c.archivedPositionToPosition
    def archivedPositionToPosition(self, s: str) -> Position | None:
        """Convert an archived position (a string) to a position."""
        c = self
        if not s:  # PR #4772
            return None
        s = g.toUnicode(s)
        aList: list[int]
        aList_s = s.split(',')
        try:
            aList = [int(z) for z in aList_s]
        except Exception:
            return None
        if not aList:
            return None
        p = c.rootPosition()
        level = 0
        while level < len(aList):
            i = aList[level]
            while i > 0:
                if p.hasNext():
                    p.moveToNext()
                    i -= 1
                else:
                    return None
            level += 1
            if level < len(aList):
                p.moveToFirstChild()
        return p

    # @+node:ekr.20150422080541.1: *4* c.backup
    def backup(
        self,
        fileName: str = '',
        prefix: str = '',
        silent: bool = False,
        useTimeStamp: bool = True,
    ) -> str | None:
        """
        Back up given fileName or c.fileName().
        If useTimeStamp is True, append a timestamp to the filename.
        """
        c = self
        fn = fileName or c.fileName()
        if not fn:
            return None
        theDir, base = g.os_path_split(fn)
        if useTimeStamp:
            if base.endswith('.leo'):
                base = base[:-4]
            if base.endswith('.leojs'):
                base = base[:-6]
            stamp = time.strftime("%Y%m%d-%H%M%S")
            branch = prefix + '-' if prefix else ''
            fn = f"{branch}{base}-{stamp}.leo"
            path = g.finalize_join(theDir, fn)
        else:
            path = fn
        if path:
            # pylint: disable=no-member
            # Issues saved message.
            c.saveTo(fileName=path, silent=silent)
            # g.es('in', theDir)
        return path

    # @+node:ekr.20180210092235.1: *4* c.backup_helper
    def backup_helper(
        self,
        base_dir: str = '',
        env_key: str = 'LEO_BACKUP',
        sub_dir: str = '',
        use_git_prefix: bool = True,
    ) -> None:
        """
        A helper for scripts that back up a .leo file.
        Use os.environ[env_key] as the base_dir only if base_dir is not given.
        Backup to base_dir or join(base_dir, sub_dir).
        """
        c = self
        old_cwd = os.getcwd()
        join = g.finalize_join
        if not base_dir:
            if env_key:
                try:
                    base_dir = os.environ[env_key]
                except KeyError:
                    print(f"No environment var: {env_key}")
                    base_dir = ''
        if base_dir and g.os_path_exists(base_dir):
            if use_git_prefix:
                git_branch, _ = g.gitInfo()
            else:
                git_branch = ''
            theDir, fn = g.os_path_split(c.fileName())
            backup_dir = join(base_dir, sub_dir) if sub_dir else base_dir
            path = join(backup_dir, fn)
            if g.os_path_exists(backup_dir):
                written_fn = c.backup(
                    path,
                    prefix=git_branch,
                    silent=True,
                    useTimeStamp=True,
                )
                g.es_print(f"wrote: {written_fn}")
            else:
                g.es_print(f"backup_dir not found: {backup_dir!r}")
        else:
            g.es_print(f"base_dir not found: {base_dir!r}")
        os.chdir(old_cwd)

    # @+node:ekr.20090103070824.11: *4* c.checkFileTimeStamp
    def checkFileTimeStamp(self, fn: str) -> bool:
        """
        Return True if the file given by fn has not been changed
        since Leo read it or if the user agrees to overwrite it.
        """
        c = self
        if g.app.externalFilesController:
            return g.app.externalFilesController.check_overwrite(c, fn)
        return True

    # @+node:ekr.20110530124245.18248: *4* c.looksLikeDerivedFile
    def looksLikeDerivedFile(self, fn: str) -> bool:
        """
        Return True if fn names a file that looks like an
        external file written by Leo.
        """
        # c = self
        try:
            with open(fn, 'rb') as f:  # 2020/11/14: Allow unicode characters!
                b = f.read()
                s = g.toUnicode(b)
            return s.find('@+leo-ver=') > -1
        except Exception:
            g.es_exception()
            return False

    # @+node:ekr.20250717132026.1: *4* c.makeLinkLeoFiles & helper
    def makeLinkLeoFiles(
        self,
        *,
        extensions: list[str],  # List of file extensions for generated @<file> nodes.
        top_directory: str,
        kind: str = '@clean',  # Any @<file> type. @clean is recommended.
        report_changed_at_clean_nodes: bool = True,  # Recommended.
        sub_directories: list[str] | None = None,
        top_outline_name: str = '',
    ) -> None:
        # @+<< c.makeLinkLeoFiles: docstring >>
        # @+node:ekr.20250717132150.1: *5* << c.makeLinkLeoFiles: docstring >>
        """
        Create a **top-level outline** containing @leo links to *sub-outlines*.
        Sub-outlines contain @<file> nodes to all files in the directory (and
        sub-directories) of the given list of extensions.

        Scripts calling this method need only specify the value of the following kwargs:

        - extensions:       List of file extensions for generated @<file> nodes.
        - kind:             One of @auto, @clean, @file, etc.
        - report_changed_at_clean_nodes:
                            The value to use for @bool report-changed-at-clean-nodes
                            in all generated outlines.
        - sub_directories:  An optional list of sub-directories in which to create sub-outlines.
                            Relative paths are relative to the top-level directory.
                            If the list is empty, it defaults to all direct directories of
                            the top-level directory.
        - top_directory:    The full, absolute, path to the top-level directory.
        - top_outline_name: The name of the top-level link outline.
        """
        # @-<< c.makeLinkLeoFiles: docstring >>
        c = self
        # @+<< return if initial checks fail >>
        # @+node:ekr.20250725152511.1: *5* << return if initial checks fail >>
        if (
            not top_directory
            or not os.path.isdir(top_directory)
            or not os.path.isabs(top_directory)
            or not os.path.exists(top_directory)
        ):
            g.es_print(f"Not an absolute directory: {top_directory!r}")
            return
        if not isinstance(extensions, (list, tuple)):
            g.es_print(f"Invalid list of extensions: {extensions!r}")
            return
        # @-<< return if initial checks fail >>
        # @+<< make sure that all extensions start with '.' >>
        # @+node:ekr.20250729064219.1: *5* << make sure that all extensions start with '.' >>
        extensions = [(z if z.startswith('.') else f".{z}") for z in extensions]
        # @-<< make sure that all extensions start with '.' >>
        if not top_outline_name:
            top_outline_name = f"{os.path.basename(top_directory)}_links.leo"
        # @+<< calculate the list of subdirectories >>
        # @+node:ekr.20250725152709.1: *5* << calculate the list of subdirectories >>
        # Default to all direct sub-directories of the top directory.
        if not sub_directories:
            sub_directories = [
                z
                for z in os.listdir(top_directory)
                if os.path.isdir(os.path.join(top_directory, z))
            ]
        # @-<< calculate the list of subdirectories >>
        g.es_print(f"Scanning {len(sub_directories)} directories.\nThis may take awhile.")
        # The main loop.
        old_p = c.p
        try:
            t1 = time.process_time()
            c.enableRedrawFlag = False
            all_files: list[str] = [top_outline_name]  # List of paths.
            top_links: list[str] = []
            for sub_directory in sub_directories:
                sub_directory = os.path.join(top_directory, sub_directory)
                assert os.path.exists(sub_directory), repr(sub_directory)
                files = []
                # @+<< find files in sub_directory >>
                # @+node:ekr.20250725163431.1: *5* << find files in sub_directory >>
                # Set files to the list of full, absolute, files in subdirectory.
                for ext in extensions:
                    new_files = glob.glob(
                        f"{sub_directory}{os.sep}**{os.sep}*{ext}",
                        recursive=True,
                    )

                    # _create_link_files converts these full paths to relative paths.
                    files.extend([z for z in new_files if os.path.isfile(z) and z not in files])
                # @-<< find files in sub_directory >>
                if files:
                    sub_outline_name = f"{g.shortFileName(sub_directory)}_links.leo"
                    # @+<< add link to the sub outline to top_links >>
                    # @+node:ekr.20250725163807.1: *5* << add link to the sub outline to top_links >>
                    abs_path = f"{sub_directory}{os.sep}{sub_outline_name}"
                    rel_link = os.path.relpath(abs_path, start=top_directory)
                    top_links.append(rel_link.replace('\\', '/'))

                    # @-<< add link to the sub outline to top_links >>
                    # @+<< compute the sub outline's back link >>
                    # @+node:ekr.20250725165018.1: *5* << compute the sub outline's back link >>
                    # Compute the relative back link for the sub-outline.
                    abs_back_link = f"{top_directory}{os.sep}{top_outline_name}"
                    rel_back_link = os.path.relpath(abs_back_link, start=sub_directory)
                    back_link = [rel_back_link.replace('\\', '/')]
                    # @-<< compute the sub outline's back link >>
                    # @+<< create the sub outline >>
                    # @+node:ekr.20250725152555.1: *5* << create the sub outline >>
                    # Generate the sub-outline
                    self._create_link_file(
                        directory=sub_directory,
                        extensions=extensions,
                        files=files,
                        kind=kind,
                        links=back_link,
                        outline_name=f"{g.shortFileName(sub_directory)}_links.leo",
                        report_changed_at_clean_nodes=report_changed_at_clean_nodes,
                    )
                    # @-<< create the sub outline >>
                    all_files.append(sub_outline_name)
            # @+<< create the top-level outline >>
            # @+node:ekr.20250725152626.1: *5* << create the top-level outline >>
            self._create_link_file(
                directory=top_directory,
                extensions=extensions,
                files=None,
                kind='@leo',
                links=top_links,
                outline_name=top_outline_name,
                report_changed_at_clean_nodes=report_changed_at_clean_nodes,
            )
            # @-<< create the top-level outline >>
            all_files = sorted(list(set(all_files)))
            if not g.unitTesting:
                t2 = time.process_time()
                g.es_print(f"Done! Created {len(all_files)} outlines in {t2 - t1:3.2} sec")
        finally:
            c.selectPosition(old_p)
            c.enableRedrawFlag = True
            c.redraw()

    # @+node:ekr.20250717132857.1: *5* c._create_link_file
    def _create_link_file(
        self,
        directory: str,
        extensions: list[str],
        files: list[str] | None,
        kind: str,
        links: list[str],
        outline_name: str,
        report_changed_at_clean_nodes: bool,
    ) -> None:
        """
        The caller is responsible for making links relative to the top-level directory.

        This method creates @<file> nodes whose paths are relative to *this* directory.
        """
        # pylint: disable=no-member
        assert os.path.exists(directory), directory

        # Create an @settings tree containing one @history-list node.
        c2 = g.app.newCommander(fileName=outline_name, gui=g.app.nullGui)

        # Create the @settings tree.
        root = c2.rootPosition()
        root.h = '@settings'
        report_p = root.insertAsLastChild()
        report_s = 'True' if report_changed_at_clean_nodes else 'False'
        report_p.h = f"@bool report-changed-at-clean-nodes = {report_s}"
        history_p = root.insertAsLastChild()
        history_p.h = '@data history-list'
        history_p.b = 'open-at-leo-file\n'
        c2.selectPosition(root)

        # Create @leo nodes.
        for link in links:
            p = c2.lastTopLevel().insertAfter()
            p.h = f"@leo {link}"

        # Create @<file> nodes for each file.
        for path in files or []:
            relative_path = os.path.relpath(path, start=directory).replace('\\', '/')
            p = c2.lastTopLevel().insertAfter()
            p.h = f"{kind} {relative_path}"

        # Create the file!
        outline_path = os.path.join(directory, outline_name)
        c2.clearChanged()  # Essential!
        c2.saveTo(fileName=outline_path, silent=True)
        c2.redraw()
        c2.close()

    # @+node:ekr.20031218072017.2925: *4* c.markAllAtFileNodesDirty
    def markAllAtFileNodesDirty(
        self,
        event: Any = None,  # not used.
    ) -> None:
        """Mark all @file nodes as changed."""
        c = self
        c.endEditing()
        p = c.rootPosition()
        while p:
            if p.isAtFileNode():
                p.setDirty()
                c.setChanged()
                p.moveToNodeAfterTree()
            else:
                p.moveToThreadNext()

    # @+node:ekr.20031218072017.2926: *4* c.markAtFileNodesDirty
    def markAtFileNodesDirty(
        self,
        event: Any = None,  # not used.
    ) -> None:
        """Mark all @file nodes in the selected tree as changed."""
        c = self
        p = c.p
        if not p:
            return
        c.endEditing()
        after = p.nodeAfterTree()
        while p and p != after:
            if p.isAtFileNode():
                p.setDirty()
                c.setChanged()
                p.moveToNodeAfterTree()
            else:
                p.moveToThreadNext()

    # @+node:ekr.20250717080554.1: *4* c.openAllLinkedFiles (transitive closure)
    def openAllLinkedFiles(self, gui: LeoGui | None = None) -> list[Commands]:
        """
        Open the transitive closure of all outlines reachable from any @leo
        node in this outline.

        Return the list of newly-opened commanders.

        Note: gui eventually defaults to g.app.gui.
        """
        c = self

        t1 = time.process_time()

        # Using the Qt gui will likely overwhelm Leo!
        if not gui:
            gui = g.app.nullGui

        # The main loop.
        result: list[Commands] = []
        scanned: list[str] = []  # List of paths already scanned.
        todo: list[str] = []

        def scan(fileName: str) -> None:
            """
            Add all paths not already seen to the todo list.
            Add all newly-opened commanders to the result list.
            """
            if fileName in scanned:
                return
            scanned.append(fileName)
            g.es_print('Scan', fileName)
            for p in c.all_unique_positions():
                if p.isAtLeoNode():
                    fileName = p.atLeoNodeName()
                    if fileName not in todo and fileName not in scanned:
                        c2 = g.openWithFileName(fileName, gui=gui)
                        todo.append(fileName)
                        assert c2
                        assert c2 not in result
                        result.append(c2)

        # Create the initial to-do list.
        scan(c.fileName())

        # Rescan until the to-do list is empty.
        while todo:
            fileName = todo.pop()
            scan(fileName)

        if not g.unitTesting:
            t2 = time.process_time()
            kind = 'hidden ' if gui == g.app.nullGui else ''
            g.es_print(f"Done! Opened {len(result)} {kind}outlines in {t2 - t1:3.2} sec")
            g.printObj(scanned, tag='Scanned')
            g.printObj(result, tag='Result')
        return result

    # @+node:ekr.20031218072017.2081: *4* c.openRecentFile
    def openRecentFile(
        self,
        event: Any = None,  # not used.
        fn: str = '',
    ) -> None:
        """
        c.openRecentFile: This is not a command!

        This method is a helper called only from the recentFilesCallback in
        rf.createRecentFilesMenuItems.
        """
        c = self
        if g.doHook("recentfiles1", c=c, p=c.p, v=c.p, fileName=fn):
            return
        if c2 := g.openWithFileName(fn, old_c=c):
            g.app.makeAllBindings()
            g.doHook("recentfiles2", c=c2, p=c2.p, v=c2.p, fileName=fn)

    # @+node:ekr.20031218072017.2823: *4* c.openWith
    def openWith(
        self,
        event: Any = None,  # not used.
        d: dict[str, Any] | None = None,
    ) -> None:
        """
        This is *not* a command.

        Handles the items in the Open With... menu.

        See ExternalFilesController.open_with for details about d.
        """
        c = self
        if d and g.app.externalFilesController:
            # Select an ancestor @<file> node if possible.
            if not d.get('p'):
                d['p'] = None
                p = c.p
                while p:
                    if p.isAnyAtFileNode():
                        d['p'] = p
                        break
                    p.moveToParent()
            g.app.externalFilesController.open_with(c, d)
        elif not d:
            g.trace('can not happen: no d', g.callers())

    # @+node:ekr.20140717074441.17770: *4* c.recreateGnxDict
    def recreateGnxDict(self) -> None:
        """Recreate the gnx dict prior to refreshing nodes from disk."""
        c, d = self, {}
        # Start with the hidden-root-vnode
        vHiddenRoot = c.hiddenRootNode
        d[vHiddenRoot.gnx] = vHiddenRoot
        # And fill up the with rest of the commander's VNodes.
        for v in c.all_unique_nodes():
            gnxString = v.fileIndex
            if isinstance(gnxString, str):
                d[gnxString] = v
                if 'gnx' in g.app.debug:
                    g.trace(c.shortFileName(), gnxString, v)
            else:
                g.internalError(f"no gnx for vnode: {v}")
        c.fileCommands.gnxDict = d

    # @+node:ekr.20180508111544.1: *3* c.Git
    # @+node:ekr.20180510104805.1: *4* c.diff_file
    def diff_file(self, fn: str, rev1: str = 'HEAD', rev2: str = '') -> None:
        """
        Create an outline describing the git diffs for all files changed
        between rev1 and rev2.
        """
        from leo.commands import editFileCommands as efc

        x = efc.GitDiffController(c=self)
        x.diff_file(fn=fn, rev1=rev1, rev2=rev2)

    # @+node:ekr.20180508110755.1: *4* c.diff_two_revs
    def diff_two_revs(self, directory: str = '', rev1: str = '', rev2: str = '') -> None:
        """
        Create an outline describing the git diffs for all files changed
        between rev1 and rev2.
        """
        from leo.commands import editFileCommands as efc

        efc.GitDiffController(c=self).diff_two_revs(rev1=rev1, rev2=rev2)

    # @+node:ekr.20180510103923.1: *4* c.diff_two_branches
    def diff_two_branches(self, branch1: str, branch2: str, fn: str) -> None:
        """
        Create an outline describing the git diffs for all files changed
        between rev1 and rev2.
        """
        from leo.commands import editFileCommands as efc

        efc.GitDiffController(c=self).diff_two_branches(branch1=branch1, branch2=branch2, fn=fn)

    # @+node:ekr.20180510105125.1: *4* c.git_diff
    def git_diff(self, rev1: str = 'HEAD', rev2: str = '') -> None:
        from leo.commands import editFileCommands as efc

        efc.GitDiffController(c=self).git_diff(rev1, rev2)

    # @+node:ekr.20230705190130.1: *4* c.git_node_history
    def git_node_history(self, file_name: str, gnx: str) -> None:
        from leo.commands import editFileCommands as efc

        efc.GitDiffController(c=self).node_history(file_name, [gnx])

    # @+node:ekr.20171124100534.1: *3* c.Gui
    # @+node:ekr.20111217154130.10286: *4* c.Dialogs & messages
    # @+node:ekr.20110510052422.14618: *5* c.alert
    def alert(self, message: str) -> None:
        c = self
        # The unit tests just tests the args.
        if not g.unitTesting:
            g.es(message)
            g.app.gui.alert(c, message)

    # @+node:ekr.20111217154130.10284: *5* c.init_error_dialogs
    def init_error_dialogs(self) -> None:
        c = self
        g.app.syntax_error_files = []
        c.ignored_at_file_nodes = []
        c.orphan_at_file_nodes = []

    # @+node:ekr.20171123135805.1: *5* c.notValidInBatchMode
    def notValidInBatchMode(self, commandName: str) -> None:
        g.es('the', commandName, "command is not valid in batch mode")

    # @+node:ekr.20110530082209.18250: *5* c.putHelpFor
    def putHelpFor(self, s: str, short_title: str = '') -> None:
        """Helper for various help commands."""
        c = self
        g.app.gui.put_help(c, s, short_title)

    # @+node:ekr.20111217154130.10285: *5* c.raise_error_dialogs
    warnings_dict: dict[str, bool] = {}

    def raise_error_dialogs(self, kind: str = 'read') -> None:
        """Warn about read/write failures."""
        c = self
        use_dialogs = False
        if g.unitTesting:
            c.init_error_dialogs()
            return
        # Issue one or two dialogs or messages.
        saved_body = c.rootPosition().b  # Save the root's body. The dialog destroys it!
        if c.ignored_at_file_nodes or c.orphan_at_file_nodes:
            g.app.gui.dismiss_splash_screen()
        else:
            # #1007: Exit now, so we don't have to restore c.rootPosition().b.
            c.init_error_dialogs()
            return
        if c.ignored_at_file_nodes:
            files = '\n'.join(sorted(set(c.ignored_at_file_nodes)))
            if files not in self.warnings_dict:
                self.warnings_dict[files] = True
                kind_s = 'read' if kind == 'read' else 'written'
                ignored_message = f"The following were not {kind_s} because they contain @ignore:"
                kind = 'read' if kind.startswith('read') else 'written'
                g.es_print(ignored_message, color='red')
                g.es_print(files)
                if use_dialogs:
                    ignored_dialog_message = f"{ignored_message}\n{files}"
                    g.app.gui.runAskOkDialog(
                        c,
                        message=ignored_dialog_message,
                        title=f"Not {kind.capitalize()}",
                    )
        # #1050: always raise a dialog for orphan @<file> nodes.
        if c.orphan_at_file_nodes:
            message = '\n'.join(
                [
                    'The following were not written because of errors:\n',
                    '\n'.join(sorted(set(c.orphan_at_file_nodes))),
                    '',
                    'Warning: changes to these files will be lost\n',
                    'unless you can save the files successfully.',
                ]
            )
            g.app.gui.runAskOkDialog(c, message=message, title='Not Written')
            # Mark all the nodes dirty.
            for z in c.all_unique_positions():
                if z.isOrphan():
                    z.setDirty()
                    z.clearOrphan()
            c.setChanged()
            c.redraw()
        # Restore the root position's body.
        p = c.rootPosition()
        assert p.v
        p.v.b = saved_body  # #1007: just set v.b.
        c.init_error_dialogs()

    # @+node:ekr.20150710083827.1: *5* c.syntaxErrorDialog
    def syntaxErrorDialog(self) -> None:
        """Warn about syntax errors in files."""
        c = self
        if g.app.syntax_error_files and c.config.getBool('syntax-error-popup', default=False):
            aList = sorted(set(g.app.syntax_error_files))
            g.app.syntax_error_files = []
            list_s = '\n'.join(aList)
            g.app.gui.runAskOkDialog(
                c,
                title='Python Errors',
                message=f"Python errors in:\n\n{list_s}",
                text="Ok",
            )

    # @+node:ekr.20031218072017.2945: *4* c.Dragging
    # @+node:ekr.20031218072017.2947: *5* c.dragToNthChildOf
    def dragToNthChildOf(self, p: Position, parent: Position, n: int) -> None:
        c, p, u = self, self.p, self.undoer
        if not c.checkDrag(p, parent):
            return
        if not c.checkMoveWithParentWithWarning(p, parent, True):
            return
        c.endEditing()
        undoData = u.beforeMoveNode(p)
        p.setDirty()
        p.moveToNthChildOf(parent, n)
        p.setDirty()
        c.setChanged()
        u.afterMoveNode(p, 'Drag', undoData)
        c.redraw(p)
        c.updateSyntaxColorer(p)  # Dragging can change syntax coloring.

    # @+node:ekr.20031218072017.2353: *5* c.dragAfter (not used)
    def dragAfter(self, p: Position, after: Position) -> None:
        c, p, u = self, self.p, self.undoer
        if not c.checkDrag(p, after):
            return
        if not c.checkMoveWithParentWithWarning(p, after.parent(), True):
            return
        assert p.v
        c.endEditing()
        undoData = u.beforeMoveNode(p)
        p.setDirty()
        p.moveAfter(after)
        p.setDirty()
        c.setChanged()
        u.afterMoveNode(p, 'Drag', undoData)
        c.redraw(p)
        c.updateSyntaxColorer(p)  # Dragging can change syntax coloring.

    # @+node:ekr.20031218072017.2946: *5* c.dragCloneToNthChildOf
    def dragCloneToNthChildOf(self, p: Position, parent: Position, n: int) -> None:
        c = self
        u = c.undoer
        undoType = 'Clone Drag'
        current = c.p
        clone = p.clone()  # Creates clone & dependents, does not set undo.
        if not c.checkDrag(p, parent) or not c.checkMoveWithParentWithWarning(clone, parent, True):
            clone.doDelete(newNode=p)  # Destroys clone and makes p the current node.
            c.selectPosition(p)  # Also sets root position.
            return
        c.endEditing()
        undoData = u.beforeInsertNode(current)
        clone.setDirty()
        clone.moveToNthChildOf(parent, n)
        clone.setDirty()
        c.setChanged()
        u.afterInsertNode(clone, undoType, undoData)
        c.redraw(clone)
        c.updateSyntaxColorer(clone)  # Dragging can change syntax coloring.

    # @+node:ekr.20031218072017.2948: *5* c.dragCloneAfter
    def dragCloneAfter(self, p: Position, after: Position) -> None:
        c = self
        u = c.undoer
        undoType = 'Clone Drag'
        current = c.p
        clone = p.clone()  # Creates clone.  Does not set undo.
        assert clone.v
        if c.checkDrag(p, after) and c.checkMoveWithParentWithWarning(clone, after.parent(), True):
            c.endEditing()
            undoData = u.beforeInsertNode(current)
            clone.setDirty()
            clone.moveAfter(after)
            clone.v.setDirty()
            c.setChanged()
            u.afterInsertNode(clone, undoType, undoData)
            p = clone
        else:
            clone.doDelete(newNode=p)
        c.redraw(p)
        c.updateSyntaxColorer(clone)  # Dragging can change syntax coloring.

    # @+node:ekr.20031218072017.2949: *4* c.Drawing
    # @+node:ekr.20080514131122.8: *5* c.bringToFront
    def bringToFront(self, c2: Commands | None = None) -> None:
        c = self
        c2 = c2 or c
        g.app.gui.ensure_commander_visible(c2)

    BringToFront = bringToFront  # Compatibility with old scripts

    # @+node:ekr.20040803072955.143: *5* c.expandAllAncestors
    def expandAllAncestors(self, p: Position) -> bool:
        """
        Expand all ancestors without redrawing.
        Return a flag telling whether a redraw is needed.
        """
        # c = self
        redraw_flag = False
        for p in p.parents():
            assert p.v
            if not p.v.isExpanded():
                p.v.expand()
                p.expand()
                redraw_flag = True
            elif p.isExpanded():
                p.v.expand()
            else:
                p.expand()
                redraw_flag = True
        return redraw_flag

    # @+node:ekr.20080514131122.20: *5* c.outerUpdate
    def outerUpdate(self) -> None:
        """Handle delayed focus requests and modified events."""
        c = self
        if not c.exists or not c.k:
            return
        # New in Leo 5.6: Delayed redraws are useful in utility methods.
        if c.requestLaterRedraw:
            if c.enableRedrawFlag:
                c.requestLaterRedraw = False
                c.redraw()
        # Delayed focus requests will always be useful.
        if c.requestedFocusWidget:
            w = c.requestedFocusWidget
            if 'focus' in g.app.debug and not g.unitTesting:
                if hasattr(w, 'objectName'):
                    name = w.objectName()
                else:
                    name = w.__class__.__name__
                g.trace('DELAYED FOCUS', name)
            c.set_focus(w)
            c.requestedFocusWidget = None
        table = (
            ("childrenModified", g.childrenModifiedSet),
            ("contentModified", g.contentModifiedSet),
        )
        for kind, mods in table:
            if mods:
                g.doHook(kind, c=c, nodes=mods)
                mods.clear()

    # @+node:ekr.20080514131122.13: *5* c.recolor
    def recolor(self, p: Position | None = None) -> None:
        """
        Force a full recolor when using the Scintilla text widget.
        This method has no effect when using the default Qt colorizer.

        Leo 6.8.10: c.redraw calls this method, so could should call this
                    method only if the code *doesn't* call c.redraw.
        """
        c = self
        colorizer = c.frame.body.colorizer
        try:
            # Only the QScintillaColorizer class and NullColorizer classes
            # have colorize methods.
            colorizer.colorize(p or c.p)
        except AttributeError:
            pass

    # Compatibility.
    recolor_now = recolor

    # @+node:ekr.20080514131122.14: *5* c.redrawing...
    # @+node:ekr.20170808014610.1: *6* c.enable/disable_redraw
    def disable_redraw(self) -> None:
        """Disable all redrawing until enabled."""
        c = self
        c.enableRedrawFlag = False

    def enable_redraw(self) -> None:
        c = self
        c.enableRedrawFlag = True

    # @+node:ekr.20090110073010.1: *6* c.redraw
    def redraw(self, p: Position | None = None) -> None:
        """
        Redraw the screen immediately.
        If p is given, set c.p to p.
        """
        c = self
        if not p:
            p = c.p
        if not p:
            return
        if not c.positionExists(p):
            g.trace(f"Invalid position: {p!r} in {c.shortFileName()}\n{g.callers(10)=}")
            c.p = c.rootPosition()
            return
        c.requestLaterRedraw = False
        c.expandAllAncestors(p)
        # Fix bug https://bugs.launchpad.net/leo-editor/+bug/1183855
        # This looks redundant, but it is probably the only safe fix.
        c.frame.tree.select(p)
        # tree.redraw_tree will change the position if p is a hoisted @chapter node.
        p2 = c.frame.tree.redraw_tree(p)
        c.selectPosition(p2 or p)  # #503.
        # Clear the redraw request, again.
        c.requestLaterRedraw = False
        c.recolor()  # Leo 6.8.10

    # Compatibility with old scripts.
    force_redraw = redraw
    redraw_after_contract = redraw
    redraw_after_expand = redraw
    redraw_after_select = redraw
    redraw_now = redraw

    # @+node:ekr.20090110073010.2: *6* c.redraw_after_head_changed
    def redraw_after_head_changed(self) -> None:
        """
        Redraw the screen (if needed) when editing ends.
        This may be a do-nothing for some gui's.
        """
        c = self
        if c.enableRedrawFlag:
            self.frame.tree.redraw_after_head_changed()
        else:
            c.requestLaterRedraw = True

    # @+node:ekr.20170908081918.1: *6* c.redraw_later
    def redraw_later(self) -> None:
        """
        Ensure that c.redraw() will be called eventually.

        c.outerUpdate will call c.redraw() only if no other code calls c.redraw().
        """
        c = self
        if not c.requestLaterRedraw and 'drawing' in g.app.debug:
            g.trace(f"set c.requestLaterRedraw: {c.shortFileName():>20} {g.callers(1)}")
        c.requestLaterRedraw = True

    # @+node:ekr.20080514131122.17: *5* c.widget_name
    def widget_name(self, widget: Widget) -> str:
        return g.app.gui.widget_name(widget) if g.app.gui else ''

    # @+node:ekr.20171124101045.1: *4* c.Events
    # @+node:ekr.20060923202156: *5* c.onCanvasKey
    def onCanvasKey(self, event: LeoKeyEvent | None = None) -> None:
        """
        Navigate to the next headline starting with ch = event.char.
        If ch is uppercase, search all headlines; otherwise search only visible headlines.
        This is modeled on Windows explorer.
        """
        if not event or not event.char or not event.char.isalnum():
            return
        c, p = self, self.p
        p1 = p.copy()
        invisible = c.config.getBool('invisible-outline-navigation')
        ch = event.char if event else ''
        allFlag = ch.isupper() and invisible  # all is a global (!?)
        if not invisible:
            ch = ch.lower()
        found = False
        extend = self.navQuickKey()
        attempts = (True, False) if extend else (False,)
        for extend2 in attempts:
            p = p1.copy()
            while 1:
                if allFlag:
                    p.moveToThreadNext()
                else:
                    p.moveToVisNext(c)
                if not p:
                    p = c.rootPosition()
                if p == p1:  # Never try to match the same position.
                    found = False
                    break
                if newPrefix := c.navHelper(p, ch, extend2):
                    found = True
                    break
            if found:
                break
        if found:
            c.redraw(p)
            c.navTime = time.time()
            c.navPrefix = newPrefix
        else:
            c.navTime = None
            c.navPrefix = ''
        c.treeWantsFocus()

    # @+node:ekr.20061002095711.1: *6* c.navQuickKey
    def navQuickKey(self) -> bool:
        """
        Return true if there are two quick outline navigation keys
        in quick succession.

        Returns False if @float outline_nav_extend_delay setting is 0.0 or unspecified.
        """
        c = self
        deltaTime = c.config.getFloat('outline-nav-extend-delay')
        if deltaTime in (None, 0.0):
            return False
        if not c.navTime:
            return False  # mypy.
        return time.time() - c.navTime < deltaTime

    # @+node:ekr.20061002095711: *6* c.navHelper
    def navHelper(self, p: Position, ch: str, extend: bool) -> str:
        c = self
        h = p.h.lower()
        if extend:
            prefix = c.navPrefix + ch
            return h.startswith(prefix.lower()) and prefix
        if h.startswith(ch):
            return ch
        # New feature: search for first non-blank character after @x for common x.
        if ch != '@' and h.startswith('@'):
            for s in (
                'button',
                'command',
                'file',
                'thin',
                'asis',
                'nosent',
            ):
                prefix = '@' + s
                if h.startswith('@' + s):
                    while 1:
                        n = len(prefix)
                        ch2 = h[n] if n < len(h) else ''
                        if ch2.isspace():
                            prefix = prefix + ch2
                        else:
                            break
                    if len(prefix) < len(h) and h.startswith(prefix + ch.lower()):
                        return prefix + ch
        return ''

    # @+node:ekr.20031218072017.2909: *4* c.Expand/contract
    # @+node:ekr.20171124091426.1: *5* c.contractAllHeadlines
    def contractAllHeadlines(
        self,
        event: Any = None,  # not used.
    ) -> None:
        """Contract all nodes in the outline."""
        c = self
        for v in c.all_nodes():
            v.contract()
            v.expandedPositions = []  # #2571
        if c.hoistStack:
            # #2380: Handle hoists properly.
            bunch = c.hoistStack[-1]
            p = bunch.p
        else:
            # Select the topmost ancestor of the presently selected node.
            p = c.p
            while p and p.hasParent():
                p.moveToParent()
        c.selectPosition(p)  # #2380: Don't redraw here.
        c.expansionLevel = 1  # Reset expansion level.

    # @+node:ekr.20031218072017.2910: *5* c.contractSubtree
    def contractSubtree(self, p: Position) -> None:
        for p in p.subtree():
            p.contract()

    # @+node:ekr.20031218072017.2911: *5* c.expandSubtree
    def expandSubtree(self, p: Position) -> None:
        # c = self
        last = p.lastNode()
        p = p.copy()
        while p and p != last:
            p.expand()
            p = p.moveToThreadNext()

    # @+node:ekr.20031218072017.2912: *5* c.expandToLevel
    def expandToLevel(self, level: int) -> None:
        c = self
        n = c.p.level()
        max_level = 0
        for p in c.p.self_and_subtree(copy=False):
            if p.level() - n + 1 < level:
                p.expand()
                max_level = max(max_level, p.level() - n + 1)
            else:
                p.contract()
        c.expansionNode = c.p.copy()
        c.expansionLevel = max_level + 1
        c.redraw()
        # It's always useful to announce the level.
        c.frame.putStatusLine(f"level: {max_level + 1}")

    # @+node:ekr.20141028061518.23: *4* c.Focus
    # @+node:ekr.20080514131122.9: *5* c.get/request/set_focus
    def get_focus(self) -> Widget:
        c = self
        w = g.app.gui and g.app.gui.get_focus(c)
        if 'focus' in g.app.debug:
            name = w.objectName() if hasattr(w, 'objectName') else w.__class__.__name__
            g.trace('(c)', name)
        return w

    def get_requested_focus(self) -> Widget:
        c = self
        return c.requestedFocusWidget

    def request_focus(self, w: Widget) -> None:
        c = self
        trace = 'focus' in g.app.debug
        if w and g.app.gui:
            if trace:
                name = w.objectName() if hasattr(w, 'objectName') else w.__class__.__name__
                g.trace('(c)', name, g.callers(2))
            c.requestedFocusWidget = w

    def set_focus(self, w: Widget) -> None:
        trace = 'focus' in g.app.debug
        c = self
        if w and g.app.gui:
            if trace:
                name = w.objectName() if hasattr(w, 'objectName') else w.__class__.__name__
                g.trace('(c)', name, g.callers(4))
            g.app.gui.set_focus(c, w)
        elif trace:
            g.trace('(c) no w')
        c.requestedFocusWidget = None

    # @+node:ekr.20080514131122.10: *5* c.invalidateFocus (do nothing)
    def invalidateFocus(self) -> None:
        """Indicate that the focus is in an invalid location, or is unknown."""
        # c = self
        # c.requestedFocusWidget = None

    # @+node:ekr.20080514131122.16: *5* c.traceFocus (not used)
    def traceFocus(self, w: Any) -> None:
        c = self
        if 'focus' in g.app.debug:
            c.trace_focus_count += 1
            g.pr(f"{c.trace_focus_count:4d}", c.widget_name(w), g.callers(8))

    # @+node:ekr.20070226121510: *5* c.treeFocusHelper & initialFocusHelper
    def treeFocusHelper(self) -> None:
        c = self
        if c.stayInTreeAfterSelect:
            c.treeWantsFocus()
        else:
            c.bodyWantsFocus()

    def initialFocusHelper(self) -> None:
        c = self
        if c.outlineHasInitialFocus:
            c.treeWantsFocus()
        else:
            c.bodyWantsFocus()

    # @+node:ekr.20080514131122.18: *5* c.xWantsFocus
    def bodyWantsFocus(self) -> None:
        c = self
        body = c.frame.body
        c.request_focus(body and body.wrapper)

    def logWantsFocus(self) -> None:
        c = self
        log = c.frame.log
        c.request_focus(log and log.logCtrl)

    def minibufferWantsFocus(self) -> None:
        c = self
        c.request_focus(c.miniBufferWidget)

    def treeWantsFocus(self) -> None:
        c = self
        tree = c.frame.tree
        c.request_focus(tree and tree.canvas)

    def widgetWantsFocus(self, w: QTextMixin) -> None:
        c = self
        c.request_focus(w)

    # @+node:ekr.20080514131122.19: *5* c.xWantsFocusNow
    # widgetWantsFocusNow does an automatic update.

    def widgetWantsFocusNow(self, w: Widget) -> None:
        c = self
        if w:
            c.set_focus(w)
            c.requestedFocusWidget = None

    # New in 4.9: all FocusNow methods now *do* call c.outerUpdate().

    def bodyWantsFocusNow(self) -> None:
        c, body = self, self.frame.body
        c.widgetWantsFocusNow(body and body.wrapper)

    def logWantsFocusNow(self) -> None:
        c, log = self, self.frame.log
        try:  # 4652
            c.widgetWantsFocusNow(log.logCtrl.widget)
        except AttributeError:
            c.widgetWantsFocusNow(log.logCtrl)

    def minibufferWantsFocusNow(self) -> None:
        c = self
        c.widgetWantsFocusNow(c.miniBufferWidget)

    def treeWantsFocusNow(self) -> None:
        c, tree = self, self.frame.tree
        c.widgetWantsFocusNow(tree and tree.canvas)

    # @+node:ekr.20031218072017.2955: *4* c.Menus
    # @+node:ekr.20080610085158.2: *5* c.add_command
    def add_command(
        self,
        menu: LeoQtMenu,
        command: Callable,
        *,  # PR #4826
        accelerator: str = '',  # Not used.
        commandName: str = '',  # Not used.
        label: str = '',  # Not used.
        underline: int = 0,
    ) -> None:
        c = self
        assert command is not None  # PR #4826
        # Command is one of two callbacks defined in createMenuEntries.

        def add_commandCallback(c: Commands = c, command: Callable = command) -> Value:
            val = command()
            # Careful: func may destroy c.
            if c.exists:
                c.outerUpdate()
            return val

        menu.add_command(
            menu,
            accelerator=accelerator,
            command=command,
            commandName=commandName,
            label=label,
            underline=underline,
        )

    # @+node:ekr.20171123203044.1: *5* c.Menu Enablers
    # @+node:ekr.20040131170659: *6* c.canClone
    def canClone(self) -> bool:
        c = self
        if c.hoistStack:
            current = c.p
            bunch = c.hoistStack[-1]
            return current != bunch.p
        return True

    # @+node:ekr.20031218072017.2956: *6* c.canContractAllHeadlines
    def canContractAllHeadlines(self) -> bool:
        """Contract all nodes in the tree."""
        c = self
        for p in c.all_positions():  # was c.all_unique_positions()
            if p.isExpanded():
                return True
        return False

    # @+node:ekr.20031218072017.2957: *6* c.canContractAllSubheads
    def canContractAllSubheads(self) -> bool:
        current = self.p
        for p in current.subtree():
            if p != current and p.isExpanded():
                return True
        return False

    # @+node:ekr.20031218072017.2958: *6* c.canContractParent
    def canContractParent(self) -> bool:
        c = self
        return c.p.parent()

    # @+node:ekr.20031218072017.2959: *6* c.canContractSubheads
    def canContractSubheads(self) -> bool:
        current = self.p
        for child in current.children():
            if child.isExpanded():
                return True
        return False

    # @+node:ekr.20031218072017.2960: *6* c.canCutOutline & canDeleteHeadline
    def canDeleteHeadline(self) -> bool:
        c, p = self, self.p
        if c.hoistStack:
            bunch = c.hoistStack[0]
            if p == bunch.p:
                return False
        return p.hasParent() or p.hasThreadBack() or p.hasNext()

    canCutOutline = canDeleteHeadline

    # @+node:ekr.20031218072017.2961: *6* c.canDemote
    def canDemote(self) -> bool:
        c = self
        return c.p.hasNext()

    # @+node:ekr.20031218072017.2962: *6* c.canExpandAllHeadlines
    def canExpandAllHeadlines(self) -> bool:
        """Return True if the Expand All Nodes menu item should be enabled."""
        c = self
        for p in c.all_positions():  # was c.all_unique_positions()
            if not p.isExpanded():
                return True
        return False

    # @+node:ekr.20031218072017.2963: *6* c.canExpandAllSubheads
    def canExpandAllSubheads(self) -> bool:
        c = self
        for p in c.p.subtree():
            if not p.isExpanded():
                return True
        return False

    # @+node:ekr.20031218072017.2964: *6* c.canExpandSubheads
    def canExpandSubheads(self) -> bool:
        current = self.p
        for p in current.children():
            if p != current and not p.isExpanded():
                return True
        return False

    # @+node:ekr.20031218072017.2287: *6* c.canExtract, canExtractSection & canExtractSectionNames
    def canExtract(self) -> bool:
        return True  # #4015.

    canExtractSectionNames = canExtract

    def canExtractSection(self) -> bool:
        c = self
        w = c.frame.body.wrapper
        if not w:
            return False
        s = w.getSelectedText()
        if not s:
            return False
        line = g.get_line(s, 0)
        i1 = line.find("<<")
        j1 = line.find(">>")
        i2 = line.find("@<")
        j2 = line.find("@>")
        return -1 < i1 < j1 or -1 < i2 < j2

    # @+node:ekr.20031218072017.2965: *6* c.canFindMatchingBracket

    def canFindMatchingBracket(self) -> bool:
        c = self
        brackets = "()[]{}"
        w = c.frame.body.wrapper
        s = w.getAllText()
        ins = w.getInsertPoint()

        # fmt: off
        c1 = s[ins]   if 0 <= ins   < len(s) else ''
        c2 = s[ins-1] if 0 <= ins-1 < len(s) else ''
        # fmt: on

        val = (c1 and c1 in brackets) or (c2 and c2 in brackets)
        return bool(val)

    # @+node:ekr.20040303165342: *6* c.canHoist & canDehoist
    def canDehoist(self) -> bool:
        """
        Return True if do-hoist should be enabled in a menu.
        Should not be used in any other context.
        """
        c = self
        return bool(c.hoistStack)

    def canHoist(self) -> bool:
        # This is called at idle time, so minimizing positions is crucial!
        """
        Return True if hoist should be enabled in a menu.
        Should not be used in any other context.
        """
        return True

    # @+node:ekr.20031218072017.2970: *6* c.canMoveOutlineDown
    def canMoveOutlineDown(self) -> bool:
        c, p = self, self.p
        return p and p.visNext(c)

    # @+node:ekr.20031218072017.2971: *6* c.canMoveOutlineLeft
    def canMoveOutlineLeft(self) -> bool:
        c, p = self, self.p
        if c.hoistStack:
            bunch = c.hoistStack[-1]
            if p and p.hasParent():
                p.moveToParent()
                return p != bunch.p and bunch.p.isAncestorOf(p)
            return False
        return p and p.hasParent()

    # @+node:ekr.20031218072017.2972: *6* c.canMoveOutlineRight
    def canMoveOutlineRight(self) -> bool:
        c, p = self, self.p
        if c.hoistStack:
            bunch = c.hoistStack[-1]
            return p and p.hasBack() and p != bunch.p
        return p and p.hasBack()

    # @+node:ekr.20031218072017.2973: *6* c.canMoveOutlineUp Bug!
    def canMoveOutlineUp(self) -> bool:
        c, current = self, self.p
        visBack = current and current.visBack(c)
        if not visBack:
            return False
        if visBack.visBack(c):
            return True
        if c.hoistStack:
            limit, limitIsVisible = c.visLimit()
            if limitIsVisible:  # A hoist
                assert limit
                return current != limit
            # A chapter.
            return current != limit.firstChild()  # type:ignore # Bug!?!
        return current != c.rootPosition()

    # @+node:ekr.20031218072017.2974: *6* c.canPasteOutline
    def canPasteOutline(self, s: str = '') -> bool:
        # c = self
        if not s:
            s = g.app.gui.getTextFromClipboard()
        # check for JSON
        if s and s.lstrip().startswith("{"):
            try:
                d = json.loads(s)
                if not ('vnodes' in d and 'tnodes' in d):
                    return False
            except Exception:
                return False
            return True
        if s and g.match(s, 0, g.app.prolog_prefix_string):
            return True
        return False

    # @+node:ekr.20031218072017.2975: *6* c.canPromote
    def canPromote(self) -> bool:
        p = self.p
        return p and p.hasChildren()

    # @+node:ekr.20031218072017.2977: *6* c.canSelect....
    def canSelectThreadBack(self) -> bool:
        p = self.p
        return p.hasThreadBack()

    def canSelectThreadNext(self) -> bool:
        p = self.p
        return p.hasThreadNext()

    def canSelectVisBack(self) -> bool:
        c, p = self, self.p
        return p.visBack(c)

    def canSelectVisNext(self) -> bool:
        c, p = self, self.p
        return p.visNext(c)

    # @+node:ekr.20031218072017.2978: *6* c.canShiftBodyLeft/Right
    def canShiftBodyLeft(self) -> bool:
        c = self
        w = c.frame.body.wrapper
        return w and w.getAllText()

    canShiftBodyRight = canShiftBodyLeft

    # @+node:ekr.20031218072017.2979: *6* c.canSortChildren, canSortSiblings
    def canSortChildren(self) -> bool:
        p = self.p
        return p and p.hasChildren()

    def canSortSiblings(self) -> bool:
        p = self.p
        return p and (p.hasNext() or p.hasBack())

    # @+node:ekr.20031218072017.2980: *6* c.canUndo & canRedo
    def canUndo(self) -> bool:
        c = self
        return c.undoer.canUndo()

    def canRedo(self) -> bool:
        c = self
        return c.undoer.canRedo()

    # @+node:ekr.20031218072017.2981: *6* c.canUnmarkAll
    def canUnmarkAll(self) -> bool:
        c = self
        for p in c.all_unique_positions():
            if p.isMarked():
                return True
        return False

    # @+node:ekr.20040323172420: *6* Slow routines: no longer used
    # @+node:ekr.20031218072017.2966: *7* c.canGoToNextDirtyHeadline (slow)
    def canGoToNextDirtyHeadline(self) -> bool:
        c, current = self, self.p
        for p in c.all_unique_positions():
            if p != current and p.isDirty():
                return True
        return False

    # @+node:ekr.20031218072017.2967: *7* c.canGoToNextMarkedHeadline (slow)
    def canGoToNextMarkedHeadline(self) -> bool:
        c, current = self, self.p
        for p in c.all_unique_positions():
            if p != current and p.isMarked():
                return True
        return False

    # @+node:ekr.20031218072017.2968: *7* c.canMarkChangedHeadline (slow)
    def canMarkChangedHeadlines(self) -> bool:
        c = self
        for p in c.all_unique_positions():
            if p.isDirty():
                return True
        return False

    # @+node:ekr.20031218072017.2969: *7* c.canMarkChangedRoots
    def canMarkChangedRoots(self) -> bool:
        c = self
        for p in c.all_unique_positions():
            if p.isDirty() and p.isAnyAtFileNode():
                return True
        return False

    # @+node:ekr.20031218072017.2990: *4* c.Selecting
    # @+node:ekr.20031218072017.2992: *5* c.endEditing
    def endEditing(self) -> None:
        """End the editing of a headline."""
        c = self
        if c.p:
            c.frame.tree.endEditLabel()

    # @+node:ville.20090525205736.12325: *5* c.getSelectedPositions
    def getSelectedPositions(self) -> list[Position]:
        """Get list of currently selected positions.

        So far only makes sense on qt gui (which supports multiselection)
        """
        c = self
        return c.frame.tree.getSelectedPositions()

    # @+node:ekr.20031218072017.2991: *5* c.redrawAndEdit
    def redrawAndEdit(
        self,
        p: Position,
        selectAll: bool = False,
        selection: tuple | None = None,
        keepMinibuffer: bool = False,
    ) -> None:
        """Redraw the screen and edit p's headline."""
        c, k = self, self.k
        c.redraw(p)  # This *must* be done now.
        if p:
            # This should request focus.
            c.frame.tree.editLabel(p, selectAll=selectAll, selection=selection)
            if k and not keepMinibuffer:
                # Setting the input state has no effect on focus.
                if selectAll:
                    k.setInputState('insert')
                else:
                    k.setDefaultInputState()
                # This *does* affect focus.
                k.showStateAndMode()
        else:
            g.trace('** no p')
        # Update the focus immediately.
        if not keepMinibuffer:
            c.outerUpdate()

    # @+node:ekr.20031218072017.2997: *5* c.selectPosition
    def selectPosition(self, p: Position, **kwargs: KWargs) -> None:
        """
        Select a new position, redrawing the screen *only* if we must
        change chapters.
        """
        trace = False  # For # 2167.
        if kwargs:
            print('c.selectPosition: all keyword args are ignored', g.callers())
        c = self
        cc = c.chapterController
        if not p:
            if not g.app.batchMode:  # A serious error.
                g.trace('Warning: no p', g.callers())
            return
        if cc and not cc.selectChapterLockout:
            # Calls c.redraw_later if the chapter changes.
            cc.selectChapterForPosition(p)
        # De-hoist as necessary to make p visible.
        if c.hoistStack:
            while c.hoistStack:
                bunch = c.hoistStack[-1]
                if c.positionExists(p, bunch.p):
                    break
                if trace:
                    # #2167: Give detailed trace.
                    print('')
                    print('pop hoist stack! callers:', g.callers())
                    g.printObj(c.hoistStack, tag='c.hoistStack before pop')
                    print('Recent keystrokes')
                    for i, data in enumerate(reversed(g.app.lossage)):
                        print(f"{i:>2} {data!r}")
                    print('Recently-executed commands...')
                    for i, command in enumerate(reversed(c.recent_commands_list)):
                        print(f"{i:>2} {command}")
                c.hoistStack.pop()
        c.frame.tree.select(p)
        # Do *not* test whether the position exists!
        # We may be in the midst of an undo.
        c.p = p

    # Compatibility, but confusing.

    selectVnode = selectPosition

    # @+node:ekr.20070226113916: *5* c.treeSelectHelper
    def treeSelectHelper(self, p: Position) -> None:
        c = self
        if not p:
            p = c.p
        if p:
            c.redraw(p)
        c.treeFocusHelper()  # This is essential.

    # @+node:ekr.20130823083943.12559: *3* c.recursiveImport
    def recursiveImport(
        self,
        *,  # All arguments are kwargs.
        dir_: str = '',  # A directory or file name.
        ignore_pattern: re.Pattern | None = None,  # Ignore files matching this regex pattern.
        kind: str = '@file',
        recursive: bool = True,
        safe_at_file: bool = True,
        theTypes: list[str] | None = None,
        verbose: bool = True,
    ) -> None:
        # @+<< docstring >>
        # @+node:ekr.20130823083943.12614: *4* << docstring >>
        """
        Recursively import all python files in a directory and clean the results.

        Parameters::
            dir_              The path to a directory or file.
                              Relative paths must exist relative to the outline's directory.
            kind              One of ('@clean','@edit','@file','@nosent').
            recursive=True    True: recurse into subdirectories.
            safe_at_file=True True: produce @@file nodes instead of @file nodes.
            theTypes=None     A list of file extensions to import.
                              None is equivalent to ['.py']
            verbose=False     True: report imported directories.

        This method cleans imported files as follows:

        - Replace backslashes with forward slashes in headlines.
        - Remove empty nodes.
        - Add @path directives that reduce the needed path specifiers in descendant nodes.
        - Add @file to nodes or replace @file with @@file.
        """
        # @-<< docstring >>
        c = self

        # Same test as RecursiveImportController.run.
        if kind not in ('@auto', '@clean', '@edit', '@file', '@nosent'):
            g.es_print(f"Invalid kind: {kind!r}")
            return

        # Import all files in dir_ after c.p.
        try:
            from leo.core import leoImport

            cc = leoImport.RecursiveImportController(
                c,
                dir_=dir_,
                ignore_pattern=ignore_pattern,
                kind=kind,
                recursive=recursive,
                safe_at_file=safe_at_file,
                theTypes=['.py'] if not theTypes else theTypes,
                verbose=verbose,
            )
            cc.run(dir_)
        except AssertionError:
            g.es_exception()
        finally:
            c.redraw()

    # @+node:ekr.20171124084149.1: *3* c.Scripting/beautifier utils
    # @+node:ekr.20260110090421.1: *4* c.beautify_with_ruff
    def beautify_with_ruff(self, contents: str, root: Position, filename: str) -> str:
        """
        Use ruff format to format a temp file.
        Return the beautified contents.
        """
        c = self

        # Calculate the arguments to ruff format.
        args = ['--silent']  # '--verbose',  # Useful only for debugging.

        # Override the line length only if given explicitly.
        # The default in Leo's pyproject.toml is 120.
        line_length = c.config.getInt('black-line-length')
        if line_length and line_length > 0:
            args.append(f"--config line-length={line_length}")

        # Use Leo's default pyproject.toml by default.
        default_toml = f"{g.app.leoEditorDir}{os.sep}pyproject.toml"
        toml = c.config.getString('black-toml')
        toml_s = toml if toml and os.path.exists(toml) else default_toml
        args.append(f"--config {toml_s}")

        # Calculate the ruff command.
        python = sys.executable
        command = f"{python} -m ruff format {' '.join(args)} {filename}"

        # Run the command.
        try:
            subprocess.Popen(command, shell=True).communicate()  # Wait for results.
            results = g.readFile(filename)
            if g.isWindows:
                results = results.replace('\r', '')
            return results
        except Exception:
            g.es_exception()
            return contents

    # @+node:ekr.20260110083713.1: *4* c.beautify_script_tree
    def beautify_script_tree(self, root: Position) -> None:
        """beautify root's entire tree. This code is not yet undoable."""
        assert root.v
        c = root.v.context
        at = c.atFileCommands
        p = c.p

        # Compute the path to the temp file.
        test_dir = g.finalize_join(g.app.leoEditorDir, 'leo', 'test')
        path = g.finalize_join(test_dir, 'beautify_node.py')
        old_contents = g.getScript(c, p, useSelectedText=False)
        ok = g.writeFile(old_contents, 'utf-8', path)
        if not ok:
            return
        results = c.beautify_with_ruff(old_contents, root, path)
        if old_contents == results:
            return

        # Update root's tree.
        d: dict[str, VNode] = {p.v.gnx: p.v for p in root.self_and_subtree()}
        if at.fast_read_into_root(c, results, gnx2vnode=d, path='', root=root):
            c.redraw()
            g.es_print(f"beautified: {root.h}", color='blue')
        else:
            g.trace('at.fast_read_into_root failed')  # Should not happen.

    # @+node:ekr.20160201072634.1: *4* c.cloneFindByPredicate & helpers
    def cloneFindByPredicate(
        self,
        generator: Callable,
        predicate: Callable,
        failMsg: str = '',
        flatten: bool = False,
        iconPath: str = '',
        undoType: str = '',
    ) -> Position | None:  # PR #4773
        """
        Traverse the tree given using the generator, cloning all positions for
        which predicate(p) is True. Undoably move all clones to a new node, created
        as the last top-level node. Returns the newly-created node. Arguments:

        generator,      The generator used to traverse the tree.
        predicate,      A function of one argument p returning true if p should be included.
        failMsg=None,   Message given if nothing found. Default is no message.
        flatten=False,  True: Move all node to be parents of the root node.
        iconPath=None,  Full path to icon to attach to all matches.
        undo_type=None, The undo/redo name shown in the Edit:Undo menu.
                        The default is 'clone-find-predicate'
        """
        c = self
        u, undoType = c.undoer, undoType or 'clone-find-predicate'
        clones, root, seen = [], None, set()
        for p in generator():
            if predicate(p) and p.v not in seen:
                c.setCloneFindByPredicateIcon(iconPath, p)
                if flatten:
                    seen.add(p.v)
                else:
                    for p2 in p.self_and_subtree(copy=False):
                        seen.add(p2.v)
                clones.append(p.copy())
        if clones:
            undoData = u.beforeInsertNode(c.p)
            root = c.createCloneFindPredicateRoot(flatten, undoType)
            for p in clones:
                # Create the clone directly as a child of found.
                p2 = p.copy()
                n = root.numberOfChildren()
                p2._linkCopiedAsNthChild(root, n)
            u.afterInsertNode(root, undoType, undoData)
            c.selectPosition(root)
            c.setChanged()
            c.contractAllHeadlines()
            root.expand()
        elif failMsg:
            g.es(failMsg, color='red')
        return root

    # @+node:ekr.20160304054950.1: *5* c.setCloneFindByPredicateIcon
    def setCloneFindByPredicateIcon(self, iconPath: str, p: Position) -> None:
        """Attach an icon to p.v.u."""
        assert p.v
        if iconPath and g.os_path_exists(iconPath) and not g.os_path_isdir(iconPath):
            aList = p.v.u.get('icons', [])
            for d in aList:
                if d.get('file') == iconPath:
                    break
            else:
                aList.append(
                    {
                        'type': 'file',
                        'file': iconPath,
                        'on': 'VNode',
                        # 'relPath': iconPath,
                        'where': 'beforeHeadline',
                        'xoffset': 2,
                        'xpad': 1,
                        'yoffset': 0,
                    }
                )
                p.v.u['icons'] = aList
        elif iconPath:
            g.trace('bad icon path', iconPath)

    # @+node:ekr.20160201075438.1: *5* c.createCloneFindPredicateRoot
    def createCloneFindPredicateRoot(self, flatten: bool, undoType: str) -> Position:
        """Create a root node for clone-find-predicate."""
        c = self
        root = c.lastTopLevel().insertAfter()
        root.h = undoType + (' (flattened)' if flatten else '')
        return root

    # @+node:peckj.20131023115434.10114: *4* c.createNodeHierarchy
    def createNodeHierarchy(
        self,
        heads: list[str],
        parent: Position,  # PR #4773
        forcecreate: bool = False,
    ) -> Position:
        """
        Create the proper hierarchy of nodes with headlines defined in 'heads' under 'parent'

        params:
        parent - parent node to start from.  Set to None for top-level nodes
        heads - list of headlines in order to create, i.e. ['foo','bar','baz']
                will create:
                  parent
                  -foo
                  --bar
                  ---baz
        forcecreate - If False (default), will not create nodes unless they don't exist
                      If True, will create nodes regardless of existing nodes
        returns the final position ('baz' in the above example)
        """
        u = self.undoer
        undoType = 'Create Node Hierarchy'
        undoType2 = 'Insert Node In Hierarchy'
        u_node = parent or self.rootPosition()
        u.beforeChangeGroup(u_node, undoType)
        changed_node = False
        for idx, head in enumerate(heads):
            # if parent = None, create top level node for first head
            if parent is None and idx == 0:
                if not forcecreate:
                    for pos in self.all_positions():
                        if pos.h == head:
                            parent = pos
                            break
                if parent is None or forcecreate:
                    u_d = u.beforeInsertNode(u_node)
                    n = self.rootPosition().insertAfter()
                    n.h = head
                    u.afterInsertNode(n, undoType2, u_d)
                    parent = n
            else:  # else, simply create child nodes each round
                if not forcecreate:
                    for ch in parent.children():
                        if ch.h == head:
                            parent = ch
                            changed_node = True
                            break
                if parent.h != head or not changed_node or forcecreate:
                    u_d = u.beforeInsertNode(parent)
                    n = parent.insertAsLastChild()
                    n.h = head
                    u.afterInsertNode(n, undoType2, u_d)
                    parent = n
            changed_node = False
        u.afterChangeGroup(parent, undoType)
        return parent  # actually the last created/found position

    # @+node:ekr.20100802121531.5804: *4* c.deletePositionsInList
    def deletePositionsInList(self, aList: list) -> None:
        """
        *Undoably* delete all vnodes corresponding to the positions in aList.
        """
        c = self
        u, undoType = c.undoer, 'c.deletePositionsInList'
        root = c.rootPosition()

        # Ensure all positions are valid.
        to_be_deleted: list[Position] = []
        for p in aList:
            if c.positionExists(p) and p not in to_be_deleted:
                to_be_deleted.append(p)
        if not to_be_deleted:
            return

        def delete(p: Position) -> None:
            if p in to_be_deleted:
                # Remove all to-be-deleted positions in p's subtree from the to-be-deleted list.
                for p2 in p.subtree():
                    if p2 in to_be_deleted:
                        to_be_deleted.remove(p2)
                # Delete p.
                bunch = u.beforeDeleteNode(p)
                p.doDelete()
                u.afterDeleteNode(c.rootPosition(), 'Inner Undo Node', bunch)
            else:
                # Recursively handle all p's children.
                for child in reversed(list(p.children())):
                    delete(child)

        # The main line. Start the recursion with the top-level nodes.
        u.beforeChangeGroup(c.p, undoType, verboseUndoGroup=True)
        to_do: list[Position] = list(reversed(list(root.self_and_siblings())))
        while to_do:
            p = to_do.pop()
            delete(p)

        # Set c.p if necessary.
        if not c.positionExists(c.p):
            c.selectPosition(c.rootPosition())
        u.afterChangeGroup(c.p, undoType, reportFlag=True)

    # For compatibility.
    undoableDeletePositions = deletePositionsInList

    # @+node:ekr.20091211111443.6265: *4* c.doBatchOperations & helpers
    def doBatchOperations(self, aList: list | None = None) -> None:
        # Validate aList and create the parents dict
        if aList is None:
            aList = []
        ok, d = self.checkBatchOperationsList(aList)
        if not ok:
            g.error('do-batch-operations: invalid list argument')
            return
        for v in list(d.keys()):
            if d.get(v, []):
                aList.sort()

    # @+node:ekr.20091211111443.6266: *5* c.checkBatchOperationsList
    def checkBatchOperationsList(self, aList: list) -> tuple[bool, dict]:
        ok = True
        d: dict[VNode, list[Value]] = {}
        for z in aList:
            try:
                op, p, n = z
                ok = (
                    op in ('insert', 'delete')
                    and isinstance(p, Position)  # PR #4767: bug fix.
                    and isinstance(n, int)
                )
                if ok:
                    aList2 = d.get(p.v, [])
                    data = n, op
                    aList2.append(data)
                    d[p.v] = aList2
            except ValueError:
                ok = False
            if not ok:
                break
        return ok, d

    # @+node:ekr.20230307155313.1: *4* c.find_b & find_h
    # @+node:ekr.20230307155313.4: *5* c.find_b
    def find_b(
        self,
        regex: re.Pattern,
        flags: re.RegexFlag = re.IGNORECASE,
        it: Iterable[Position] | None = None,
    ) -> list[Position]:
        """
        Return list of all Positions whose body matches the regex at least once.
        """
        c = self
        if it is None:
            it = c.all_positions()
        try:
            pattern = re.compile(regex, flags)
            return [p.copy() for p in it if any(m for m in re.finditer(pattern, p.b))]
        except Exception:
            g.es_error('Exception in c.find_b')
            g.es_exception()
            return []

    # @+node:ekr.20230307155313.3: *5* c.find_h
    def find_h(
        self,
        regex: re.Pattern,
        flags: re.RegexFlag = re.IGNORECASE,
        it: Iterable[Position] | None = None,
    ) -> list[Position]:
        """
        Return list of all Positions whose headline matches the regex.
        """
        c = self
        if it is None:
            it = c.all_positions()
        try:
            pattern = re.compile(regex, flags)
            return [z.copy() for z in it if re.match(pattern, z.h)]
        except Exception:
            g.es_error('Exception in c.find_h')
            g.es_exception()
            return []

    # @+node:ekr.20171124155725.1: *3* c.Settings
    # @+node:ekr.20171114114908.1: *4* c.registerReloadSettings
    def registerReloadSettings(self, obj: object) -> None:
        """Enter object into c.configurables."""
        c = self
        if obj not in c.configurables:
            c.configurables.append(obj)

    # @+node:ekr.20170221040621.1: *4* c.reloadConfigurableSettings
    def reloadConfigurableSettings(self) -> None:
        """
        Call all reloadSettings method in c.subcommanders, c.configurables and
        other known classes.
        """
        c = self
        table = [
            g.app.gui,
            g.app.pluginsController,
            g.app.externalFilesController,
            c.k.autoCompleter,
            c.frame,
            c.frame.body,
            c.frame.log,
            c.frame.tree,
            c.frame.body.colorizer,
            getattr(c.frame.body.colorizer, 'highlighter', None),
        ]
        for obj in table:
            if obj:
                c.registerReloadSettings(obj)
        # Useful now that instances add themselves to c.configurables.
        c.configurables = list(set(c.configurables))
        c.configurables.sort(key=lambda obj: obj.__class__.__name__.lower())
        for obj in c.configurables:
            # An official alias.
            if func := (
                getattr(obj, 'reloadSettings', None) or
                getattr(obj, 'reload_settings', None)
            ):  # fmt: skip
                try:
                    func()  # pylint: disable=not-callable
                    g.doHook("after-reload-settings", c=c)
                except Exception:
                    g.es_exception()
                    c.configurables.remove(obj)

    # @-others


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