File: pyedit-products/unzipped/textEditor.py

#!/usr/bin/env python3
r"""
################################################################################
PyEdit: a Python/tkinter text-file editor program and component.

-Copyright: © 2000-2024 M. Lutz, learning-python.com, all rights reserved. 
-License: provided freely but with no warranties.  See terms-of-use.txt.
-Original version from the book Programming Python, 2nd-4th Editions (PP2E-PP4E)
-[4.0] Now requires Python 3.5+ (for f-strings) and psutil (for process info).

Uses the Tk text widget, plus GuiMaker menus and toolbar buttons, to implement
a full-featured text editor and code launcher that can be run as a standalone
program, and attached as a library component to other GUIs.  Also used by the
PyMailGUI and PyView programs to edit mail text and image-file notes, and by 
PyMailGUI and PyDemos in pop-up mode to display source and text files.

PROGRAM USE: 
  Run this main script (by click, command-line, IDLE Run option, etc.) to 
  start PyEdit, either with no arguments to open files in the GUI, or with one
  argument giving the pathname of a file to be opened and loaded initially:

      [python3|py -3] textEditor.py [filepath]

  Edit file textConfig.py to customize PyEdit appearance and behavior.  Some
  status messages are printed to the console if PyEdit is started from one.
  You can also run this script in PyEdit's Run Code, once PyEdit is started.
  As of 4.0, soure-code runs require Pthon 3.5+ and recommend Python 3.12+.

LIBRARY USE:
  PyEdit can also be imported and used by other programs as GUI component
  or popup display; see its top-level classes near the end of this module. 

DISTRIBUTIONS:
  As of version 3.0, PyEdit is available both as this source, and as a frozen
  app or executable on Mac, Windows, and Linux.  The latter support opens by 
  associations, and require no Python install.  The source-code version is 
  also shipped as part of PyMailGUI 4.0.  See README.txt for more details.
  [4.0] Linux executable dropped due to library issues: use the source code.

TEXT POLICIES:
  PyEdit opens and saves files using a Unicode encoding that you may input
  or hardcode (see textConfig.py); reads files having any end-line format;
  and saves files using the hosting platform's end-line format (see utility
  fixeoln.py in tools/ if you need to change end-lines in a saved file).

CODE STRUCTURE:
  For historical reasons, this single file has most of the code, with major 
  sections for Run Code, Grep, auto-saves, and already-open checks.  It also
  employs GuiMaker in PP4E/Gui/Tools (here or in a parent folder) for menus 
  and toolbar, textConfig.py file for user settings, and subprocproxy.py for
  running Python scripts.  Dev mode tweaks sys.path in fixfrozenpaths.py.

KNOWN ISSUES:
  This program's Grep no longer seems to work in IDLE as of 3.0 ([4.0] tbd),
  most likely due to its shift to multiprocessing instead of threads; run 
  PyEdit by app, filename click, or command line instead if required for 
  Grep searches.  Also, tkinter GUIs can't spawn tkinter GUIs on Android
  (GUIs fail in Run Code), and in 4.0 there are known file-dialog crashes 
  on macOS that may requires alt-dialog configs (though multi-platform/Tk 
  Unicode search crashes in Tk were fixed); see [4.0] workarounds ahead.
  On macOS, PyEdit 3.0 memory use grew over extended use; 4.0 status tbd.

NEW in version 4.0 (Dec-2024, yes, 7.5 years later):
  -Support emojis (and all non-BMPs) with mods, if underling Tk supports them
  -Support dark mode automatically, if underlying Tk supports it
  -Add Cross-process test and verify for already-open files in Open
  -Run in-process and cross-platform already-open tests for Save too
  -Automatically remove Windows GUI blurring, for both exe and source
  -Use true platform default encoding in locale for open, save, grep
  -Tweaked cosmetics for changes in recent Tks on some hosts (macOS)
  -Help now opens online version; a local copy is no longer shipped
  -Config option for custom file dialogs on macOS, if system dialogs crash
  -Rebuild standalone apps/exes use newer Python 3.12 and Tk 8.6.13
  -No Linux executable is now built: use the source-code package instead
  -Linux toolbar now uses buttons too, instead of feedback-less labels
  -Integrate Android patches: fonts, toolbar, help dialogs, init size
  -Font zoom/cycle/pick no longer change window size along with text size
  -Don't show half-built GUI if passed non-existent filename as cmd arg
  -Size windows to not exceed screen size on Android when plausible
  -Add disabled os.chdir() line for Android explorers and shortcuts
  -Fix dialog Help by disabling vertical resize: Change, Grep, Run, Font
  -Rewrite dialog help to word-wrap to current dialog size to avoid munges
  -Set initial input-field focus in Grep, Run, Font, not just Change
  -New config to accentuate Find matches with underline, bold, macOS color
  -Drop full stdlib in executable subprocproxy: users should set configs
  -Fixed errors for unrecognizable \? str escapes in py3.13+ for source
  -Splash screen for startup and Run Code pauses in Windows executable
  -Look for '[4.0]' here and in textConfig.py+guimaker.py for all 4.0 mods.

NEW in version 3.0 (June-2017, standalone post PP4E):
  -non-BMP Unicode replacements for Tk ~8.6, font/color-list configs...
  -custom icons, dialog help and shortcut keys, font prefill=current...
  -auto-save with self-cleaning, grep search stats, menu accelerator keys...
  -colored cursors, auto color cycling, better close tests, help dialog...
  -apps and executables, case toggle in search dialogs, font zoom, line wrap...
  -grep via processes vs threads, run-code dialog and output capture...
  -and Mac OS X port changes: menu modals decorator, dialog titles...
  -search for '[3.0]' here and in textConfig.py for all 3.0 changes.
  -search for 'RunningOnMacOS' to find changes related to macOS (f.k.a. Mac OS X).

NEW in version 2.1 (2010, PP4E)
  -updated to run under Python 3.X, only (py3.1+)
  -added "grep" search menu option and dialog: threaded external files search
  -verify app exit on quit if changes in other edit windows in process
  -supports arbitrary Unicode encodings for files: per textConfig.py settings
  -update change and font dialog implementations to allow many to be open
  -runs self.update() before setting text in new editor for loadFirst (not in 3.0)
  -various improvements to the Run Code option:
   *use base name after chdir to run code file, not possibly relative path 
   *use launch modes that support arguments for run code file mode on Windows
   *run code inherits launchmodes backslash conversion (no longer required)

NEW in version 2.0 (2006, PP3E)
  -added simple font components input dialog 
  -use Tk 8.4 undo stack API to add undo/redo text modifications
  -now verifies on quit, open, new, run, only if text modified and unsaved
  -searches are case-insensitive now by default
  -configuration module for initial font/color/size/searchcase

An early version 1.1 appeared in PP2E in 2000 (and in retrospect was
surprisingly full-featured at just 500 lines of Python 2.X code).

TBDs (and suggested exercises):

Older ideas...
  -use re patterns for searches: too complex for users (see text chapter)?
  -experiment with syntax-directed text colorization (see IDLE, others)?
  -try to verify program exit for quit() in non-managed windows too?
  -queue each result as found in grep dialog thread to avoid delay?
  -use images in toolbar buttons (per examples of this in Chapter 9)?
  -scan line to map Tk insert position column to account for tabs on Info?
  -experiment with "grep" tbd Unicode issues (see notes in the code)?
  -consider spellchecking, possibly via a web-based service?
  -get Unicode encodings in open/save dialogs' GUIs, instead of asking?

Newer ideas...
  -use a nested package + __main__.py to avoid trying imports two ways?
  -use new font-selection dialog in latest Tk (but limits Py versions)?
  -build proxy as pyinstaller one-dir to reduce Win/Lin start-up time?
  -keep just one RunCode window per PyEdit window and raise on Run?

  -make all standalone windows Tk()s so quit when _last open_ is closed:
   there is a dependency on a long-lived root for auto-save after() events;
   alterntively, a dummy main window could be withdrawn and retained;

  -emojis are replaced for display, but dropped _permanently_ on saves;
   surrogateescape can retain content for saves, but emojis display and 
   edit badly, and this also breaks display and edit of otherwise valid 
   BMP Unicode symbols; see docetc/examples/Assorted-demos/Non-BMP-Emojis 
   ([4.0] emojis are now supported where supported by the underlying Tk)

  -On Mac Tk 8.5, Quit sometimes switches to an irrelevant desktop; why?
  -On Mac Tk 8.5, closed windows leave phantom enties on Dock; try Tk 8.6?
  -On Mac, the main window's Quit could just iconify() to avoid full exits

Recent fixes...
  -DONE: sanitize non-BMP Unicode characters in info/error dialogs too
  -DONE: Mac OpenDoc event failed for filenames with emojis (see __main__)
  -DONE: tkinter's file dialogs save a non-BMP initialfile/initialdir that 
   can cause subsequent calls to fail; fixed for Open and SaveAs; Grep's 
   Browse folder dialogs are okay - they use function calls, not objects
  -DONE: Mac system restarts trigger rare "-psn" names: see openAllDocs()
  -DONE: avoid momentary console on RunCode kills in frozen Windows exes
  -DONE: allow search case choice in GUI (not just config file)
  -DONE: add accelerators for menu items on Mac OS X: no Alt+<underline>
  -DONE: package as a standalone 'app' for Mac, exe for Windows + Linux
  -And so on - see version history above for more recent mods...
################################################################################
"""




#===============================================================================
# (Some) major [3.0] additions (also search for "[3.0]" and "[4.0]")
#===============================================================================


"""
--------------------------------------------------------------------------------
[3.0] General and initial Mac OS X porting notes:

PyEdit's menu items automatically show up in the top-of-screen menu bar on Mac
(as normal and expected).  Some dialog titles were tweaked here for the Mac.
Mac dialogs can also be slide-downs (via parent=win) but are not here, because
using popup windows in a multiwindow interface seems more flexible and natural.
UPDATE: parent=self is now used on Mac too, else root is lifted above subject.

Alt+<underline> menu keyboard shortcuts don't work on Mac - likely need to also
support "accelerator" options and bindings in GuiMaker.  As is, menus can be
navigated by keyboard on  Mac (ctrl+fn+F2, letter1, space, letter1), but it's
cumbersome; for now, added Undo and Redo to all toolbars for easier access.
UPDATE: menu accelerators _have_ been added, and tailored to the Mac's keys.

Mac menus remain always active and can reopen an already-open modal dialog,
which can cause havoc.  This seems paridigm skew, but duplicate modal actions
are disabled here via a decorator to avoid the issue altogether.  Mac menus also
don't have Tk tearoffs - between this and lack of Alt+* shortcuts, they seem
a bit less useful.  Mac menus also add some items "for free" that need to be
replaced (e.g., About), and have inheritance issues that may be Tk 8.5 specific.
--------------------------------------------------------------------------------
"""


# [4.0] ANDROID: remove '#' and use your quoted unzip path for explorer opens+shortcuts
#import os; os.chdir('/sdcard/Download/PyEdit--source')

# these are tedious to repeat
import sys
RunningOnMacOS     = sys.platform.startswith('darwin')
RunningOnWindows   = sys.platform.startswith('win')
RunningOnLinux     = sys.platform.startswith('linux') 

RunningOnAndroid   = hasattr(sys, 'getandroidapilevel')           # [4.0] py 3.7+
RunningOnLinuxOnly = RunningOnLinux and not RunningOnAndroid      # [4.0] non-andr


#===============================================================================


"""
[3.0] For frozen apps/exes, fix module+resource visibility.  This logic 
and its docs have now been moved off to file fixfrozenpaths.py.  Importing
it auto-configures the sys.path import path (but not CWD) in-place and 
needed for the freeze tool and platform being used, to grant importers 
access to modules.  It's a no-op for some source-code contexts.

Seperately, fixfrozenpaths() portably determines the program's install folder, 
which hosts resources like icons and the suprocproxy.  Use this instead of 
__file__ directly, which doesn't generally work in PyInstaller executables 
for user-accessible resources.  This function uses __file__ for source/py2app,
else sys.executable, which == sys.argv[0] unless exe is a symlink.

Always try the . import first: it's crucial that this gets its own version.
SNEAKY BIT: fixfrozenpaths also adds a parent folder for dev source-code mode.
"""

try:
    from . import fixfrozenpaths    # get mine if I'm part of a package
except (ImportError, SystemError):
    import fixfrozenpaths           # used here only in PyEdit itself

# [3.0] data+scripts not in os.getcwd() if run from a cmdline elsewhere,
# and __file__ may not work if running as a frozen PyInstaller executable;
# pass __file__ of this file for Mac apps, not module: it's in a zipfile;

INSTALLDIR = fixfrozenpaths.fetchMyInstallDir(__file__)   # absolute


#===============================================================================


def try_set_window_icon(window, prog='pyedit', kind='-main', trace=False):
    r"""
    [3.0] For standalone windows, replace generic Tk or system icon with a
    custom icon - window icon on Windows, app bar icon on Linux, TBD on Mac
    (Mac requires app bundles to support most icon contexts; see py2app,
    and PyInstaller in [4.0]).
 
    Linux needs a gif, else requires Tk 8.6+ for pngs (or a Pillow install).
    When fetching icons from PyEdit's own folder, can get path via __file__,
    whether imported in package or run standalone, and without importing
    self; see PP4E/Gui/Tools/windows.py for more on local folder access.
    Update: see fixfrozenpaths.py for new policy: __file__ may not work.

    findicon() tries the current working dir first, then pyedit's own subdir.
    Hence, in embdedded mode, windows use a hosting app's icon if one exists,
    else pyedit's own; in standalone mode, windows use pyedit's own icon.
    
    'prog' and 'kind are used to build a filename for pyedit's own folder;
    'kind' can be used for a more-specific icon - popup windows use special
    'pyeditpopup.ico' when standalone to distinguish from main/quitting Tk(); 
    
    Caveat: could use PP4E.Gui.Tools.windows superclasses, but it's more
    complex to integrate with those classes' cannned APIs for quits, etc.
    Caveat: tkinter's askstring() and askinteger() don't pick up custom
    icons, but they can be patched to do so (at some peril) => see ahead.
    Caveat: tkinter's askcolor() displays no icon and cannot be patched,
    and ditto for its automatic save-as dialog's overwite warning popup.

    [4.0] DON'T look for icon in CWD - that could be anywhere a program
    is run from, and it's often the clicked file's folder in explorers.  
    Using CWD caused an unrelated user .ico to override PyEdit's.  BUT DO  
    look up one level from INSTALLDIR if it ends in the component subdir, 
    in order to use an embedding app's icon.  fixfrozenpaths always sets 
    INSTALLDIR to either PyEdit install dir or a __pyedit-component-data__
    subdir of the embedding app's install dir with PE's icons/ and more.

    [4.0] This ALSO EXPLAINS why, on Windows, launching PyEdit for a file 
    with Explorer's "Open With" showed a bizarre onedrive BLUE-CLOUD icon 
    instead of PyEdit's icon.  Clicking on a file with associations runs
    PyEdit with CWD set to the file's folder properly, but "Open With"
    runs PyEdit with CWD instead set to C:\Windows\System32... which just 
    happens to have a blue-cloud OneDrive.ico file (shung!).  Skipping CWD
    here also avoids this peculiar blue-cloud icon for "Open With".  It's 
    not known why "Open With" works this way; it's wrong for program's files
    and may happen only for admin users, but its icon botching is now moot.
    """
    
    def findicon(ext):
        # from __file__ for source, sys.executable for PyInstaller exe
        pyeditdir = INSTALLDIR

        # [4.0] not cwd, but look up if component subfolder
        if pyeditdir.endswith('__pyedit-component-data__'):
            upone = os.path.dirname(pyeditdir)
            iconsparent = glob.glob(f'{upone}{os.path.sep}*.{ext}')
        else:
            iconsparent = []

        # handle all other cases per fixfrozenpaths
        iconname = f'{prog}-window{kind}.{ext}'
        iconmine = os.path.join(pyeditdir, 'icons', iconname)

        iconpick = iconsparent[0] if iconsparent else iconmine
        if trace: print(f'{iconpick=}')
        return iconpick
    
    try:
        if RunningOnWindows:
            window.iconbitmap(findicon('ico'))            # Windows: all contexts

        elif RunningOnLinuxOnly:                          # [4.0] not Android: moot
            imgobj = PhotoImage(file=findicon('gif'))     # Linux: app bar, Tk 8.5+
            window.iconphoto(True, imgobj)                # use Gif for Tk 8.5-

        elif RunningOnMacOS or RunningOnAndroid or True:
            raise NotImplementedError                     # macOS (or other): neither

    except Exception as why:
        pass   # bad file or platform


#===============================================================================


"""
--------------------------------------------------------------------------------
tkinter dialog window-border patches

[3.0] The following extends two classes in the tkinter module to add custom
icons to the standard modal dialogs askstring() and askinteger().  Unlike
most common dialogs, these two always display the default Tk icon without the
code below (even if a parent is specified), and have no icon protocol support
themselves.  Caveat: these classes are semi-private ("_"), and open to future
changes that may break this code (really, hack, but there's no alternative).

Caveat: askcolor() displays no icon (even if a parent/master is passed in),
and seems unable to be patched to use a custom icon (there is no Toplevel to
use in an extended method).  This is less grievous than other ask*(): punt!

Caveat: the SaveAs dialog also posts a dialog without title or icon when the
user selects an existing file (an overwrite warning).  There seems no way to
improve this, as it's issued by Tk's common dialogs internally: also punt!
UPDATE: actually, for this the Mac app shows a slidedown with a small version 
of the PyEdit icon, along with the warning symbol imgage - this seems fine;
--------------------------------------------------------------------------------
"""

from tkinter.simpledialog import _QueryString, _QueryInteger


class PatchAskString(_QueryString):
    """
    A TopLevel (by inheritance), which interacts in its __init__.
    Extend its widget-builder method to set the window's custom
    icon per the hosting platform.  Note: this cannot extend the
    __init__ method, as that's where all user interaction occurs.
    Also note: this must return entry for initial focus to be set.
    """
    def body(self, master):                  
        entry = _QueryString.body(self, master)
        try_set_window_icon(self)
        return entry


class PatchAskInteger(_QueryInteger):
    """
    Ditto - see preceding class's docstring.
    """
    def body(self, master):
        entry = _QueryInteger.body(self, master)
        try_set_window_icon(self)
        return entry


def my_askstring(title, prompt, **kargs):
    return PatchAskString(title, prompt, **kargs).result


def my_askinteger(title, prompt, **kargs):
    return PatchAskInteger(title, prompt, **kargs).result


#===============================================================================


"""
--------------------------------------------------------------------------------
More tkinter dialog patches: pass parent arg (but allow for omission on Mac)

[3.0] Tk's common dialogs on Windows lift the root window above the subject
window when they differ, unless a parent=self argument is included.  On Mac,
this is not the case for simple dialogs like showinfo, but is for others.
This is also done for Open/Save dialogs, still coded as instance methods ahead.

On Mac, parent=self unfortunately(?) also invokes slide-down sheet style instead
of a popup window, and discards the dialog's window title, which may or may not
be preferred - hence the encapsulation here for possible future changes.
Caveat: these perhaps should be methods, but this grew from a simple fix.

Note that passing a "master=self" argument has no effect on root window lifts,
and askstring/askinteger still popup in a window (but now don't raise the root).
While we're at it, also add appname to title automatically here (not per call),
and restore parent focus botched by most dialogs in ActiveState Tk 8.5 on Mac.
--------------------------------------------------------------------------------
"""

AnyDlgParents = True   # use parent=self anywhere, to avoid root lifts?
MacDlgParents = True   # use parent=self on Mac, and accept slide-downs?


def dlgRefocus(self):
    """
    [3.0] On Mac OS X only (and using ActiveState's Tk 8.5: others TBD),
    all standard dialogs except askstring and askinteger do not restore
    focus to the parent window on close, even when the parent=self argument
    is passed; users must click to edit.  This forces focus back to parent
    with a focus_force() on self.text; neither focus_force() on self, nor
    focus_set() on self or self.text suffice in this context.  transient()
    may help (unverified), but is unsupported by most standard dialogs.
    """
    if isinstance(self, TextEditor):
        self.text.focus_force()          # TextEditor window?
    elif self != None:
        self.focus_force()               # allow generic popup too
    else:
        pass                             # allow standalones too

    
def dlgParent(self, orphan):
    """
    Allow for omissions, via parent=dlgparent(self, orphan) in any 3.X.
    Or: return dict(parent=self) and use **dlgparent(self) in py3.5+.
    Change global constants or pass orphan=True to tailor parentage.
    """
    if (not AnyDlgParents) or orphan:
        return None
    elif (not MacDlgParents) and RunningOnMacOS:
        return None
    else:
        return self


def callDialog(dialog, self, context, message, orphan, pargs, kargs):
    """
    Factor wrapper logic here.
    Example: Help=>About is still a popup (orphan).
    This also sanitizes (replaces) any non-BMP Unicode 
    message text for Tk, else the GUI may fail or hang.
    """
    if hasattr(self, 'appname'):
        applabel = self.appname + ' - '   # allow non-TextEditor parents
    else:                                 # also verified when refocus
        applabel = '' 

    result = dialog(                      # base tkinter or patched dialog
        applabel + context,               # title where shown: 'PyEdit - Open'
        fixTkBMP(message),                # prompt or message text (sanitized)
        *pargs,                           # any extra positional args
        parent=dlgParent(self, orphan),   # use self as parent or not
        **kargs)                          # any extra keyword args

    dlgRefocus(self)                      # else Mac OS X requires a click
    return result


# patch common dialogs: pass kwonly orphan=True to omit parent (see onHelp)

from tkinter.messagebox import showinfo, showerror, askyesno

def my_showinfo(self, context, message, *pargs, orphan=False, **kargs):
    return callDialog(showinfo, self, context, message, orphan, pargs, kargs)

def my_showerror(self, context, message, *pargs, orphan=False, **kargs):
    return callDialog(showerror, self, context, message, orphan, pargs, kargs)

def my_askyesno(self, context, message, *pargs, orphan=False, **kargs):
    return callDialog(askyesno, self, context, message, orphan, pargs, kargs)


# and patch the already-patched input dialogs by redefinition

_askstring  = my_askstring
_askinteger = my_askinteger

def my_askstring(self, context, message, *pargs, orphan=False, **kargs):
    return callDialog(_askstring, self, context, message, orphan, pargs, kargs)

def my_askinteger(self, context, message, *pargs, orphan=False, **kargs):
    return callDialog(_askinteger, self, context, message, orphan, pargs, kargs)


#===============================================================================


def modalMenuAction(method):
    """
    [3.0] A DECORATOR - easier than inserting pre+post action code.
    For Mac OS X, disable all other menu actions that may trigger
    modal dialogs if one is already in progress.  '@'-decorate all
    menu callbacks that may open modal dialogs with this no-argument
    function.  This should be a no-op outside Mac, and harmless
    (other platforms disable a window's menus during modal dialogs).
    See earlier note above for more on modal dialogs and Mac menus.
    """
    def onCall(*pargs, **kargs):                   # saves method in func scope 
        if TextEditor.modalisopen: 
            return                                 # skip call if already modal 
        else:
            TextEditor.modalisopen = True          # lock new requests out now
            try:
                res = method(*pargs, **kargs)      # original method (with self)
                return res                         # and finally runs before exit
            finally:
                TextEditor.modalisopen = False     # enable new requests again
    return onCall                                  # method name = wrapper


def allowModals():
    """
    [3.0] In two cases (onCut, onPaste), a modal menu action calls other
    modal menu actions: forcibly free modal lock so the others can run.
    Two others (save, refind) call modals immediately: don't decorate.
    """
    TextEditor.modalisopen = False


#===============================================================================


def grepThreadProducer(filenamepatt, dirname, grepkey, encoding, case, myqueue):
    """
    --------------------------------------------------------------------
    Moved from class to top-level function so it can be run by the
    multiprocessing module as a workaround for a Python 3.5/Tk 8.6
    random thread crash.  See the class's grep code for the caller.
    
    In a non-GUI parallel thread or process: queue find.find results
    list.  Could also queue matches as found, but need to keep window.
    Note that file content and file names may both fail to decode here.

    TBD: should the match here be case-insensitive per textConfig?
    [3.0] YES: recoded for new policy = case-insensitive by default,
    with a new 'Case?' GUI toggle for sensitive (either may be valid);

    TBD: could pass encoded bytes to find() to avoid filename
    decoding excs in os.walk/listdir, but which encoding to use:
    sys.getfilesystemencoding() if not None?  see also Chapter6 
    footnote issue: py3.1 fnmatch always converts bytes per Latin-1.

    [3.0] Tally and pass to consumer a few search statistics;
    it's important to show how many files were skipped due to
    Unicode errors, so the user can retry with another encoding.

    [4.0] Android doesn't support Python's multiprocessing module
    (something about a missing semaphore call in its custom C libs):
    run this via _thread on this platform via config-file presets.

    [4.0] Nits: the filename match is always case INsensitive, per the
    Python fnmatch module; should there be a toggle for case sensitive?
    There also is no support here for Windows too-long pathnames, though 
    this can now be neutralized by users in the Python Windows install.
    Finally, this waits till all lines are in; per-line posts helpful?
    
    --------------------------------------------------------------------
    [3.0] THE TALE OF THE GREP-THREAD CRASH WORKAROUNDS...
    
    TAKE 1: speculative recodings
    
    This code occasionally crashed due to a threading bug in the
    combination of Python 3.5 and Tk 8.6 (at least), described here:
    learning-python.com/books/python-changes-2014-plus.html#s35E.

    As possible fixes, this was recoded to (1) avoid any possible
    uncaught exceptions in the non-GUI thread, and (2) explicitly
    close input files, though no evidence has ever been found to
    support either theory, and neither should have resulted in a
    hard crash (at best, these may have triggered an unrelated bug).

    The GUI consumer code (in the main class) was also recoded to
    (3) sanitize and truncate result list inserts, but this proved
    irrelevant - the crashes occur before results are pulled from
    the queue.  In the end, NONE of these three recodings were seen
    to have fixed the Tk crash (yes, argh); maybe Tk 8.7 or 8.5 will...
    
    TAKE 2: use processes instead of threads (despite the function name!)

    The prime suspect now appears to be Python's threading module,
    because Python's more basic _thread module is used extensively in
    the PyMailGUI program without any issues.  Hence, the grep spawn
    code has now been recoded to experiment with all the alternatives:
    threading and _thread's threads, and multiprocessing's processes.
    The latter is used by default (this can be set in textConfig.py). 

    multiprocessing has some downsides:
    - It necessitated moving the parallel task's code here (it requires
      a pickleable callable - a top-level function, or an instance of a
      top-level subclass with run()).
    - It is broken for frozen single-file executable programs (pickle
      imports fail), and required a workaround patch for this context.
      See multiprocessing_exe_patch.py and __main__ for more details.
    - It may startup more slowly (it spawns a new python program on
      Windows and forks a new process on Unix)
    - It cannot do freely-shared state quite like threads (e.g., it
      can't pass object method callables).

    OTOH, multiprocessing sidesteps thread issues completely, and
    runs *faster* where it can leverage multiple CPU cores.  On one
    multicore Windows test machine, N grep processes may run N times
    faster than threads (each gets as much CPU as a single threaded
    process), and the story is similar on Mac OS X (processes can
    consume more CPU time than threads, and finish noticably quicker).

    In addition, state is a moot point here (grep queues just a list
    of strings, not PyMailGUI's callables), and this code can easily
    revert to using threads in the future, because multiprocessing
    exports largely-compatible interfaces.

    Plus, multiprocessing works around the Tk and/or Python thread
    crash.  Such is development in the world of battery dependency.
    
    UPDATE AND CAVEAT: per later usage, it appears that Python 3.5's
    libs can still hard-crash (segfault) on very rare occasions while 
    reading a next line in some UTF-8 files (sigsegv on Mac, at least).
    This may or may not be related to the original crash, and may or 
    may not be triggered by a specific file's unusual content.  It's 
    also a dead end for this program; is it fixed in later Pythons?
    Either way, using processes is warranted by improved speed alone.
    --------------------------------------------------------------------
    """
    from PP4E.Tools.find import find    # uses fnmatch.fnmatch

    # in py3.3+, casefold() is like lower(), but handles Unicode better
    folder = getattr(str, 'casefold', str.lower)
    if not case:
        grepkey = folder(grepkey)                         # [3.0]

    nmatch = nfile = nuerr = nierr = nxerr = nterr = 0    # [3.0]
    matches = []
    try:
        for filepath in find(pattern=filenamepatt, startdir=dirname):
            nfile += 1
            textfile = None
            try:
                textfile = open(filepath, encoding=encoding)
                for (linenum, linestr) in enumerate(textfile):
                    linestr0 = linestr                   
                    if not case:                              # queue orig case
                        linestr = folder(linestr)             # [3.0] 'a'=='A'?
                    if grepkey in linestr:
                        nmatch += 1                           # drop \n for GUI list
                        linestr0 = linestr0.rstrip('\n')
                        msg = '%s@%d  [%s]' % (filepath, linenum + 1, linestr0)
                        matches.append(msg)
            except UnicodeError as X:
                # eg: decode, bom
                nuerr += 1                                    # escape non-ASCII 
                print('Unicode error in:', ascii(filepath), type(X))
            except IOError as X:
                # eg: permission
                nierr += 1
                print('IO error in:', ascii(filepath), type(X))
            except Exception as X:
                # any others? [3.0]
                nxerr += 1
                print('Other error in:', ascii(filepath), type(X))
                print(ascii(sys.exc_info()))
            finally:
                if textfile: textfile.close()                 # always close [3.0]
    except:
        # find excs (filenames?), or any other uncaught (prints?)
        # catch and end exc, instead of propagating with finally [3.0]
        nterr += 1
        print('Uncaught error in grep task:', sys.exc_info()[0])

    print('Matches for %s: %s' % (grepkey, len(matches)))
    summary = '%d %d %d %d %d %d' % (nmatch, nfile, nuerr, nierr, nxerr, nterr)    
    matches.insert(0, summary)      # [3.0] prepend summary line
    myqueue.put(matches)            # stop consumer loop now, no active exc


#===============================================================================


"""
[3.0] Hideous workaround for multiprocessing and Windows frozen executables.

See multiprocessing_exe_patch.py here plus __main__ for all the gory details.
This code is used both as top-level script and module within package, and
the import statement form varies for these two cases in 3.X (a 3.X "feature").
"""

import multiprocessing
try:
    import multiprocessing_exe_patch          # fix multiprocessing in-place
except ImportError:
    from . import multiprocessing_exe_patch   # and when I'm part of a package


#===============================================================================


# [4.0] Needed for cross-process already-open test (psutil is 3rd party dep)

trace = print
import time      # plus glob and os ahead

try:
    import psutil
except ImportError:
    print('Required psutil dependency absent.')
    print('To fix, run "pip3 install psutil" or "py -3.12 -m pip install psutil".')
    sys.exit(1)


#===============================================================================


# [4.0] needed for platform Unicodenecoding default fix

import locale




#===============================================================================
# (Mostly) original PP4E code follows (but see also "[3.0]"s ahead)
#===============================================================================


Version = '4.0'                                   # 4.0 = 2024, 3.0 = post PP4E
import sys, os, glob                              # platform, args, run tools
from tkinter import *                             # base widgets, constants
from tkinter.filedialog   import Open, SaveAs     # standard dialogs
from tkinter.colorchooser import askcolor
from PP4E.Gui.Tools.guimaker import *             # Frame + menu/toolbar builders


# [3.0] no longer used directly - see custom versions above
# from tkinter.simpledialog import askstring, askinteger 

# [3.0] no longer used directly - see custom versions above
# from tkinter.messagebox import showinfo, showerror, askyesno

# [4.0] alternative custom file dialogs - see my_ask{saveas,open}filename
from tkinter.filedialog import LoadFileDialog, SaveFileDialog


# general configurations: from first dir on import path (sys.path)
try:
    import textConfig                        # startup font and colors
    Configs = textConfig.__dict__            # work if not on the path or bad
except:                                      # define in client app directory 
    Configs = {}


# a few global Tk constants
START     = '1.0'                   # index of first char: row=1,col=0 (vs END)
SEL_FIRST = SEL + '.first'          # map sel tag to index
SEL_LAST  = SEL + '.last'           # same as 'sel.last'

FontScale = 0                                    # use bigger font on macOS and Linux
if not (RunningOnWindows or RunningOnAndroid):   # but not on Windows or Android [4.0]
    FontScale = 3




#----------------------------------------------------------------------------
# [4.0] Check for Tk version that supports emojis, once, at import|startup.
# This must be here instead of earlier, because it uses a config-file setting.
# Now also passed to GuiMaker init to disable toolbar tweaks on older Tks.
#----------------------------------------------------------------------------

try:
    import tkinter
    _fullTkVersion = tkinter.Tcl().call('info', 'patchlevel')       # not Tk(): dflt win
    _fullTkVersion = [int(n) for n in _fullTkVersion.split('.')]    # N.M.O 3-item list
    assert len(_fullTkVersion) == 3
except:
    print('[no Tk full version number]')
    _fullTkVersion = [0, 0, 0]
    _TkSupportsNonBMPs = False                                      # punt: replace
else:
    _mintkversion = Configs.get('minimumTkForEmojis', [8, 6, 12])   # default and preset
    _TkSupportsNonBMPs = _fullTkVersion >= _mintkversion            # no non-BMPs before?

# context, please!
print(f'Py={sys.version_info[:3]}, Tk={_fullTkVersion}, {_TkSupportsNonBMPs=}')


def fixTkBMP(text):
    """
    --------------------------------------------------------------------------
    [3.0] (copied from PyMailGUI) Tk <= 8.6 cannot display Unicode characters
    outside the U+0000..U+FFFF BMP (UCS-2) code-point range, and generates
    uncaught exceptions when tried (emojis kill programs!).  To address this,
    call this function to sanitize all text passed to the GUI for display.
    It replaces any non-BMP characters with the standard Unicode replacement
    character U+FFFD, which Tk displays as a highlighted question mark diamond.
    This workaround is coded to assume that Tk 8.7 will lift the BMP restriction,
    per a dev rumor.  It also assumes TkVersion has been imported from tkinter.

    There are related issues in tkinter file dialogs ("initialfile" has to be
    forced to None to avoid later errors if a filename with an emoji is chosen);
    prints to stdout (text must be forced to ascii() to avoid errors on some
    consoles); and the Mac's OpenDocument event in __main__ (either Tk, tkinter,
    or both munge filenames with emojis, requiring an odd encode+decode to open).
 
    [4.0] Skip replacement here for Tks that support non-BMP Unicode characters.
    Also hack isNonBMP to fool its callers into skipping non-BMP special ops.
    --------------------------------------------------------------------------
    """

    # [4.0] works in py3.12+tk8.6.13, fails in py3.8+tk8.6.8 (tk9.0 rel oct24)
    if _TkSupportsNonBMPs:
        #print('Skipping non-BMP replace')   # now validated
        return text

    """
    # [4.0] workaround on SO fails in py3.8 & 3.12 - a temp tk hack before tk8.6.8?
    sys.path.append('__private__/tkinter-emojis-oct24')
    from make_surrogates import with_surrogates
    return with_surrogates(text)
    """

    # [2.0] original replacement code (tkinter.TkVersion)
    if TkVersion <= 8.6:
        text = ''.join((ch if ord(ch) <= 0xFFFF else '\uFFFD') for ch in text)
    return text


def isNonBMP(text):
    """
    --------------------------------------------------------------------------
    [3.0] Return true if any character (codepoint) in text is outside Tk's BMP 
    display range.  Used by Open/Save dialogs to ignore prior saved choice for
    which this returns True , else tkinter fails in the dialog's show() calls.
    Also used by onOpen to issue a warning popup when characters are replaced.
    --------------------------------------------------------------------------
    """

    if _TkSupportsNonBMPs:
        return False            # [4.0] always false if non-BMP OK (see fixTkBMP)

    if TkVersion <= 8.6:
        return any(ord(ch) > 0xFFFF for ch in text)
    else:
        return False   # and assume Tk 8.7 will make this better...


#----------------------------------------------------------------------------
# for Help button and menu About popups (now along with HTML help [3.0]);
# raw Unicode chars work because Py source encoding default is UTF-8 [3.0];
# that is, this source file needs no "# -*- coding: UTF-8 -*-" at its top;
# example: for copyright, use either \u00A9 escape or a raw © character;
# end lines in HelpText WITHOUT a trailing space - it matters in mods ahead;
#----------------------------------------------------------------------------

HelpText = f"""PyEdit

Version {Version}, December 2024

A text-editor and code-runner program and component.
PyEdit is open source,
uses Python 3.X and tkinter for its GUI,
and runs on macOS, Windows, Linux, and Android.

© M. Lutz 2000-2024. Originally from
the book "Programming Python, 4th Edition" (PP4E),
2011, O'Reilly Media.

For quick access to menu actions, use the toolbar,
accelerator-key shortcuts, and menu tear-offs and
Alt-underline shortcuts where supported. For help
with dialogs, see their Help buttons. For in-depth
usage details and license, tap "User Guide."

PyEdit Version History

● {Version}: Dec, 2024
● 3.0: Jun, 2017
● 2.1: Apr, 2010
● 2.0: Jan, 2006
● 1.0: Oct, 2000

⬥ Version {Version} adds
cross-process checks for already-open files,
support for emojis and dark mode when supported by the underlying Tk,
automatic deblurring of the GUI on Windows,
enhanced Android support,
zooms sans window-size changes,
and rebuilt PC apps/exes with a newer Python and Tk.

⬥ Version 3.0 was a standalone release that added
custom icons,
non-BMP Unicode replacements,
font- and color-list configs,
dialog help and keys,
color cycling,
auto-saves,
grep search stats,
colored cursors,
menu accelerator keys,
font zoom,
line wrap modes,
toolbar fonts,
in-process checks for already-open files,
case toggles for searches,
parallel grep processes,
run-code dialog and stream capture,
exe and app bundle distributions,
and full utility on macOS
in addition to Windows and Linux.

⬥ Version 2.1 in PP4E was ported to Python 3.X
and added a "grep" external-files search dialog,
verified quits for changed text in any edit window,
arbitrary Unicode encodings for files,
support for multiple change and font dialogs,
and run-code upgrades.

⬥ Versions 2.0 and 1.0 appeared in PP3E and PP2E,
where 1.0 introduced core utility,
and 2.0 added a font-pick dialog,
unlimited undo/redo,
smarter save prompting only for changed text,
case-insensitive search,
and configuration module textConfig.py.

For more info on version mods, click the "User Guide" button."""

# fill-in version number - now via f-string [4.0]
# HelpText = HelpText % ((Version,) * 3)

# [3.0] make help look nicer outside Windows (see also HTML help)
HelpText = HelpText.replace('\n', ' ')          # merge lines into paragraph
HelpText = HelpText.replace('  ', '\n\n')       # restore blank lines
HelpText = HelpText.replace(' ●', '\n   ● ')    # fix version bullet list (●, •, ♦)
HelpText = HelpText.replace('\n●', '\n   ● ')   # the first is an oddball

# [3.0] on Windows, the hands are illegible in the system font
# used by the infobox common dialog, and no way to set font (?)
# [4.0] hand dropped, ⭑ was too small on most hosts (use ⬥)
# if RunningOnWindows:
#     HelpText = HelpText.replace('☞', '⇨')     # ☞ beats ☛ on Mac; ★ on all

# [3.0] on Linux, specialize too-large bullets (silly, but true)
if RunningOnLinuxOnly:                          # but not on Android [4.0]
    HelpText = HelpText.replace('●', '•')       # else huge in info box on Linux

# [4.0] diamond too small on android (what nonsense)
if RunningOnAndroid:
    HelpText = HelpText.replace('⬥', '●')

# yes, these render differently on Windows/Linux and Mac...
dialogHelpBullet = '•' if RunningOnMacOS else '●'




################################################################################
# Main class: implements editor GUI, actions (code grouped by menus);
# requires a flavor of GuiMaker to be mixed in by more specific subclasses;
# not a direct subclass of GuiMaker because that class takes multiple forms.
################################################################################


class TextEditor:
    """
    --------------------------------------------------------------------
    TextEditor methods: mix with GuiMaker menu/toolbar Frame class,
    and embed in a parent window when being used in standalone mode.
    Class-level names defined here are shared by all windows unless redef.
    --------------------------------------------------------------------
    """

    # class-level data    
    openwindows  = []            # for process-wide change-test and auto-save
    modalisopen  = False         # [3.0] process-wide modal lock, Mac OS X menus
    autosaving   = False         # [3.0] start just one auto-save timer loop
    namelessid   = 0             # [3.0] autosave filenames: init, New, not Open
    appname      = 'PyEdit'      # [3.0] for GuiMaker automatic help menu text
    openprograms = []            # [3.0] for process-wide spawnee kills at close


    #---------------------------------------------------------------------
    # Unicode policy configurations: from pyedit's own config file;
    # imported in the class to allow overrides in subclass or self;
    # this file is both script and module: py3.X imports need help,
    # unless split importable parts off from __main__ to nested pkg
    #---------------------------------------------------------------------
    
    if __name__ == '__main__':
        from textConfig import (               # my dir is on the path
            opensAskUser, opensEncoding,
            savesUseKnownEncoding, savesAskUser, savesEncoding)
    else:
        try:
            from .textConfig import (          # 2.1: always from this package
                opensAskUser, opensEncoding,
                savesUseKnownEncoding, savesAskUser, savesEncoding)

        except (ImportError, SystemError):     # [4.0] it's now ImportError...
            from textConfig import (           # [3.0] unless multiprocessing...
                opensAskUser, opensEncoding,   # values irrelevant but must load
                savesUseKnownEncoding, savesAskUser, savesEncoding)


    #---------------------------------------------------------------------
    # file-open common type filters
    # [3.0] these are pointless on Mac OS X (and are disabled there ahead)
    # [4.0] not anymore - macOS shows in both saves and opens, though opens 
    # require an Options press, and saves don't highlight but add extensions
    # (at least on a Catalina dev machine: macOS's UI is a frequent morpher)
    #---------------------------------------------------------------------

    filetypes = [('All files',     '*'),              # for file open dialog
                 ('Text files',   '.txt'),            # customize in subclass
                 ('Python files', '.py')]             # or set in each instance


    # dialogs that remember the last dir selected, created on first use;
    # in retrospect, probably just as easy to save last folder manually;
    # [3.0] for ease of use these are now process-global, not per-window

    openDialog = None
    saveDialog = None


    #--------------------------------------------------------------------
    # first folder for open/save dialogs
    # tbd: set to None=omitted, so gui picks last visited (like Grep)?
    # [3.0] avoid starting in '.' source-code folder where possible
    # [3.0] this is also now used in Run Code's Sting mode, as a CWD
    # [4.0] now configurable, because folder nav on mobiles is awful
    # [4.0] dialogs now remember and reuse last folder picked, possibly
    # per Open/Save and platform idiom (this may vary for native/custom).
    #---------------------------------------------------------------------

    
    startfiledir = Configs.get('fileDialogsStartFolder', None)

    if startfiledir == None or not os.path.isdir(startfiledir):
        startfiledir = os.environ.get('HOME',        # Unix (Mac, Linux, Android)
                       os.environ.get('HOMEPATH',    # Windows (no HOME)
                       '.'))                         # else my source dir

    #--------------------------------------------------------------------
    # [4.0] discourage home (~) on theory that it triggers macOS sandbox hard crash:
    # - Update: this proved pointless: use Full Disk Access or tkinter dialog instead;
    # - Update: Full Disk Access didn't avoid another macOS intermittent hard crash;
    # also use shared-storage root on Android, else must nav up from install dir;
    # these could be configs and could use Documents elsewhere too, but tmi?
    # - Update: now a config (textConfig.py) with default above on all platforms;
    #--------------------------------------------------------------------
    """CUT
    if RunningOnMacOS:
        __macosdocs = startfiledir + '/Documents'    # always exists (presumably)
        if os.path.isdir(__macosdocs):               # class-private attr for temp
            startfiledir = __macosdocs 
   
    elif RunningOnAndroid:
        __androidshared = '/storage/emulated/0'      # = /sdcard, but easier nav ~ drives
        if os.path.isdir(__androidshared):
            startfiledir = __androidshared    
    CUT"""


    #------------------------------------------------------
    # menu Tools=>Color List presets (+ main setting):
    # applies next one each time Color List is selected;
    # foreground/background, colorname or #RRGGBB hexstr;
    # users can also pick colors in GUI, but temporary;
    #
    # [3.0] fg used for cursor too, else lost in dark bg;
    # [3.0] also now used for auto-color cycling on open;
    # [3.0] now a config, so this list is a default+demo;
    #
    # [4.0] nit: this could include optional selection 
    # fg+bg colors too, else default may be too subtle
    # for dark bgs; there are also inactiveselection 
    # fg+bg (managed by Find) which matters on macOS, 
    # but this gets to be silly and tmi at some point; 
    #------------------------------------------------------
    
    colors = [                                        # color pick list
        {'fg': 'white',      'bg': '#173155'},        # muted dark blue [4.0] 66~55
        {'fg': 'wheat',      'bg': 'black'},          # see also Configs['bg'/'fg']
        {'fg': 'black',      'bg': 'lightcyan'},      # tailor these as desired
        {'fg': 'white',      'bg': '#004400'},        # [4.0] was lighter 'darkgreen'
        {'fg': 'white',      'bg': '#800040'},        # maroon - or so they say
        {'fg': 'black',      'bg': '#e4c0a7'},        # light mocha
        {'fg': 'white',      'bg': '#006060'},        # teal [4.0] 80~70
        {'fg': 'black',      'bg': '#d0fffb'},        # three from the website
        {'fg': 'black',      'bg': '#fff5dc'},        # green?, beige?, teal?
        {'fg': 'black',      'bg': '#ddfaff'}, 
        {'fg': 'green2',     'bg': 'black'},          # 3270 terminal, anyone?
        {'fg': '#00ffff',    'bg': '#3b3b3b'},        # a touch of grey
       #{'fg': 'white',      'bg': '#664e38'},        # chocolate maybe? ([4.0] yuck)
        {'fg': 'black',      'bg': '#f1fdfe'},        # one from pymailgui 
        {'fg': 'black',      'bg': 'wheat'},
        {'fg': '#ffffff',    'bg': '#400080'},        # it's white on purple...
        {'fg': '#ff0000',    'bg': '#000000'},        # red on black (mar/lic)
        {'fg': 'black',      'bg': '#ffb368'},        # orange, but not hurty
        {'fg': 'black',      'bg': '#ffff99'},        # a less-rude yellow
        {'fg': '#00ffff',    'bg': '#000080'},        # turquoise/midnight [sic]
        {'fg': 'black',      'bg': 'white'},          # sans colors
        {'fg': 'black',      'bg': '#00ffff'},        # black on cyan (probably)    
        {'fg': 'black',      'bg': 'aquamarine'},     # a sort of greenish
        {'fg': 'black',      'bg': '#f99b94'},        # was darker 'indian red'
       #{'fg': 'cornsilk',   'bg': '#A28264'},        # brown and proud of it ([4.0 meh)
        {'fg': 'orange',     'bg': 'navy'},
        {'fg': '#ffffff',    'bg': '#633025'},        # a better brown
        {'fg': 'black',      'bg': 'beige'}]          # and then basic fg/bg
    
    if 'colorlist' in Configs:
        colors = Configs['colorlist']   # [3.0] get from textConfig file if set


    #------------------------------------------------------
    # Demo menu Tools=>Font List presets (+ main setting):
    # applies next one each time Font List is selected;
    # (family, size, style), style can be multiple words;
    # users can also pick fonts in GUI, but temporary;
    # Tk guarantees courier, helvetica, and times;
    # [3.0] now a config, so this list is a default+demo;
    #------------------------------------------------------

    fonts  = [
        ('courier',       8+FontScale, 'normal'),     # cross-platform, mostly
        ('courier',      10+FontScale, 'normal'),     # (family, size, style)
        ('courier',      10+FontScale, 'bold'),
        ('courier',      10+FontScale, 'italic'),     # or Pick Font chooser
        ('courier',      12+FontScale, 'normal'),     # bigger fonts on Unix
        ('courier',      12+FontScale, 'bold'),
        ('times',        12+FontScale, 'normal'),     # 'bold italic' if 2
        ('helvetica',    10+FontScale, 'normal'),     # also 'underline',...
        ('arial',        10+FontScale, 'normal'),     # tbd: show in listbox?
        ('courier',      16+FontScale, 'bold'),
        ('courier',      18+FontScale, 'normal'),
        ('helvetica',    10+FontScale, 'underline'),
        ('monaco',       12+FontScale, 'normal'),     # fixed-width on some
        ('menlo',        12+FontScale, 'normal'),     # mac os x font: only?
        ('lucinda sans', 12+FontScale, 'normal'),     # fixed-width on some
        ('consolas',     12+FontScale, 'normal'),     # fixed-width on some
        ('inconsolata',  12+FontScale, 'normal'),     # fixed-width on some
        ('courier new',  11+FontScale, 'normal'),     # where != 'courier' 
        ('courier new',  11+FontScale, 'bold'),       # differs on Mac
        ('tahoma',       11+FontScale, 'normal'),     # nice on all
        ('symbol',       11+FontScale, 'normal'),     # wacky on Windows 
        ('herculanum',   13+FontScale, 'normal'),     # mac+? (odin's font?)
        ('papyrus',      13+FontScale, 'normal'),     # mac+win (just for yucks)
        ('impact',       12+FontScale, 'normal')]     # poster-like, win+mac
    
    if 'fontlist' in Configs:
        fonts = Configs['fontlist']     # [3.0] get from textConfig file if set 
    



    ############################################################################
    # General methods
    ############################################################################

    
    def __init__(self, loadFirst='', loadEncode=''):
        """
        --------------------------------------------------------------------
        What the TextEditor class requires, after GuiMaker.__init__.
        TextEditor is really just a pacjage of mixin methods which 
        must be combined with a GuiMaker Frame subclass and attached
        to a container created seperately.  Run order, Main and Popup:

            TexEditor top-level code
            TextEditorMain(TextEditor, GuiMakerWindowMenu) =>
                GuiMaker.__init()__ => 
                    start(), accBindWidget(), makeWidgets()
                TextEditor.__init__()
 
        See top-level classes ahead for other protocol calls run.
        By the time this is called, the menu and toolbar have been
        built, and makeWidgets() has packed text in the middle.
        Any self-level names defined here are local to this window.
        --------------------------------------------------------------------
        """
        if not isinstance(self, GuiMaker):
            raise TypeError('TextEditor needs a GuiMaker mixin')

        self.setFileName(None)
        self.lastfind = None                        # init this window's state
        self.knownEncoding = None                   # 2.1 Unicode: till Open or Save
        self.text.focus()                           # else must click in text

        #self.openDialog = None                     # [3.0] now process-global
        #self.saveDialog = None

        # [4.0] size new window, main or popup, to the screen per configs
        self.scaleWindowToScreen(self.master)       # especially on Android

        # [4.0] for inactive-select-bg restores on Find dialog closes on macOS
        self.numFindDialogsOpen = 0    # this edit window, don't care after it's closed

        #--------------------------------------------------------------------
        # [3.0] update() is no longer required: see setAllText()
        # [4.0] actually, update() IS required here for the case where a
        # non-existent filename is pased in as cmdline argument - without
        # update() the window is not fully drawn when the "Could not open"
        # dialog appears; with update(), the file is not auto-created
        # (should it be?-TBD), but at least the GUI is sound; also note
        # that update_idletasks() doesn't suffice here (despite web noise!);
        #--------------------------------------------------------------------

        if loadFirst:
            self.update()                           # [4.0] doit; [2.1] else @ line 2 - see book
            self.onOpen(loadFirst, loadEncode)      # this might not open a file

        # [3.0] auto-save: nameless filename ids (1 per edit window + New) and loop 
        TextEditor.namelessid += 1                  # autosave filenames seq#
        self.namelessid = TextEditor.namelessid     # save current count on me
        if not TextEditor.autosaving:
            self.autoSaveLoop()                     # start just one timer loop
            TextEditor.autosaving = True
                
        # [3.0] window tracking
        # auto-register every open window - both top-level and component;
        # this list is used for change-tests on quit [2.1] and auto-saves [3.0];
        TextEditor.openwindows.append(self)
        
        # [3.0] auto-deregister every window when destroyed
        def deregisterTracking(event):
            """
            ------------------------------------------------------------
            called on the <Destroy> event of editor's Text widget;
            this Tk event is fired after a window's tkinter destroy()
            method is run, but neither is invoked on app-wide quit() 
            (see also docetc/examples/*/demo-tk-destroy-events.py);
            when run, self is viable, but the widget is half dead:
            this handler can't test for changes, fetch text, etc.;
            ------------------------------------------------------------
            """
            print('PyEdit got <Destroy>')
            TextEditor.openwindows.remove(self)

        self.text.bind('<Destroy>', deregisterTracking)


    def scaleWindowToScreen(self, root):
        """
        --------------------------------------------------------------------
        [4.0] On Android (Pydroid 3 app), avoid off-screen content by 
        sizing windows to initially be as large as the physical display 
        less a configurable margin - unless sizing is configured off.
        This differs from limitWindowToScreen which simply limits size,
        and runs for all Tk|Toplevel edit windows in TextEditor.__init__.

        Ths sizing here is ignored for the first window in Pydroid 3's 
        maximized mode; but for all other windows in this mode, and all
        windows in non-maximized node, this avoids off-screen content.

        Enabled as preset/default in textConfig.py if RunningOnAndroid.
        Can enable on other platforms too and can disable for larger 
        Android tablets, but crucial on smaller Android mobiles.

        'root' is a Tk or Toplevel, possibly self.master set by tkinter.
        'mg' is a fudge factor to accommodate window borders used for 
        both X and Y and '+0+0' is the offset to the top left corner;
        combined effect leaves space at the right and bottom of window.
        All values in the 'widthxheight+x+y' geo string are in pixels.

        Nit: Frigcal has better ways to let the user config this (e.g.,
        by (0.60, 0.80) percentages of screen width/height, and more).
        On/off+margins here seems limited, especially on large tablets.
        --------------------------------------------------------------------
        """
        if Configs.get('sizeWindowToScreenEnable', RunningOnAndroid):
            mgx = Configs.get('sizeWindowToScreenMarginX', 120)
            mgy = Configs.get('sizeWindowToScreenMarginY', 180)

            screen_width  = root.winfo_screenwidth()
            screen_height = root.winfo_screenheight()

            root.geometry(f'{screen_width - mgx}x{screen_height - mgy}+0+0')


    def limitWindowToScreen(self, root):
        """
        --------------------------------------------------------------------
        [4.0] On Android (Pydroid 3 app) only, always set the window's 
        maxsize limit to constrain width to that of the display.  Run
        for Toplevel windows that are not a TextEditor: custom dialogs.

        Unlike scaleWindowToScreen, which sets window size to screen 
        size always and is used for all edit windows, this simply limits
        the window's size without expanding it to display size.
 
        That makes this more useful for smallish dialogs like Find:
        there's no reason to epand them to display size, but we don't 
        want them to get any wider than the display.  Not configurable 
        because always useful on Android, and make no sense elsewhere.
        Margin is less here: better for dialogs to overlay edit windows.
        --------------------------------------------------------------------
        """
        if not RunningOnAndroid:
            return
        else:
            mg = 80                                        # less a margin
            screen_width  = root.winfo_screenwidth()       # Nit: cache me
            screen_height = root.winfo_screenheight()
            root.maxsize(screen_width - mg, screen_height - mg)


    def start(self):
        """
        --------------------------------------------------------------------
        Run by GuiMaker.__init__, via the top-level classes ahead:
        set menu/toolbars, before accBindWidget() and makeWidgets().
        Coded as an instance method, so actions have access to a self.
        Underlines: [Alt+<menuchar1>,<key>] shortcuts on Windows/Linux.
        
        [3.0] Added menu accelerator keys; these are in addition to the
        Alt-key underline shortcuts on Windows and Linux, but underlines
        don't work on the Mac, and its menu is farther away at screen top.
        Underlines also fail on Windows/Linux in embedded Frame menus,
        and are no longer displayed in this context.
        
        Most of the magic here occurs in utility ../Tools/guimaker.py.
        In accelerators, '*'/'?' stand for platform-specific keys (e.g.,
        '*' is Command on Mac and displays as an icon; it means Control
        on Windows/Linux and displays as 'Ctrl+').  More details ahead.
        
        [3.0] Note that some menu/toolbar options handled explictly here
        also have preset Text-widget binding equivalents with automatic
        actions (e.g., ctrl|cmd-c/v for copy/paste, ctrl|cmd-z for undo),
        which are disabled by the same-key accelerators specified here.
        The built-ins update the widget's changed flag and undo stacks.

        [3.0] Reorganized menus and their underline shortcut keys to
        highlight most commonly used, and added a few more separators.
        Also reorganized and expanded the toolbar, and allow its layout
        style and font to be configured in textConfig.py; use the space.
        --------------------------------------------------------------------
        """

        #-------------------------------------------------------------------
        # Configure menubar - a GuiMaker menu-def tree:
        #
        #   [(label,
        #     [(label, underline-shortcut, handler, accelerator-shortcut?)]]
        #
        # In underlines, the value is the label character's offset. 
        # In accelerators, '*'=cmd|ctl and '?'=ctl|alt on mac|others:
        #   -on Mac, '*-f' = cmd+f, '?-f' = ctl+f, '?-*-f' = ctl+cmd+f
        #   -on Win, '*-f' = ctl+f, '?-f' = alt+f, '?-*-f' = alt+ctl+f
        # Note: built-in bindings are auto-disabled by the GuiMaker utility.
        # Caution: see top-level component classes if File menu is changed.
        #
        # Caveat: Ctl+Cmd+key triples fail when embedded in PyMailGUI - why?
        # Caveat: some accelerators override Alt-key combos; use the former.
        # Caveat: Cmd+Shift combos don't seem to work on Mac in AS TK 8.5.
        # Caveat: Cmd-equals/plus don't display in menus on Mac (use other).
        #-------------------------------------------------------------------
        
        self.menuBar = [
            ('File', 0,                                 # [3.0] reorg, add septs                                
                 [('Open...',
                       0, self.onOpen,      '*-o'),     # components accs too
                  ('New',
                       0, self.onNew,       '*-n'),     # new file, this window
                  '----',
                  ('Save',
                       0, self.onSave,      '*-s'),     # first and later saves
                  ('Save As...',
                       5, self.onSaveAs,    '?-s'),     # save under a new name
                  '----',
                  ('Quit...',
                       0, self.onQuit,      '?-q')]     # was '?-*-q', but fails
            ),
            ('Edit', 0,
                 [('Undo',
                       0, self.onUndo,      '*-u'),     # not ctrl-z: built-in
                  ('Redo',
                       0, self.onRedo,      '*-r'),     # shift-cmd-z not on mac
                  '----',
                  ('Cut',
                       0, self.onCut,       '?-c'),     # or Copy+(bkspc|fn+del)
                  ('Copy',
                       3, self.onCopy,      '*-c'),     # same as built-in copy
                  ('Paste',
                       0, self.onPaste,     '*-v'),     # like built-in paste
                  '----',
                  ('Delete',
                       0, self.onDelete,    None),      # select+(bkspc|fn+del)
                  ('Select All',
                       0, self.onSelectAll, '*-a')]
            ),
            ('Search', 0,                               # [3.0] new separators
                 [('Goto...',
                       0, self.onGoto,      '*-l'),     # goto a numbered line
                  '----',
                  ('Find...',
                       0, self.onFind,      '?-f'),     # first simple find 
                  ('Refind',
                       0, self.onRefind,    '?-g'),     # find simple again 
                  ('Change...',
                       0, self.onChange,    '*-f'),     # dialog best for finds
                  '----',
                  ('Grep...',
                       3, self.onGrep,      '*-g')]     # files search dialog
            ),
            ('View', 0,                                 # [3.0] +zoom, old Tools
                 [('Zoom In',
                       5, self.onFontPlus,  '?-i'),     # incr font size+config
                  ('Zoom Out',                          # plus: Mac shift fails
                       5, self.onFontMinus, '?-o'),     # decr font size+config
                  '----',                               # minus: Mac not shown
                  ('Font List',                         # next in presets list
                       0, self.onFontList,  'F1'),      # was '?-*-f', but fails
                  ('Pick Font...',
                       0, self.onPickFont,  'F2'),      # or choose in dialog
                  '----',
                  ('Color List',                        # next in presets list
                       0, self.onColorList, 'F3'),      # was '?-*-c' but fails
                  ('Pick Bg...',
                       5, self.onPickBg,    'F4'),      # or choose in dialog
                  ('Pick Fg...',
                       6, self.onPickFg,    'F5'),      # or choose in dialog
                  '----',
                  ('Line Wrap',                         # [3.0] toggle wrapping
                       5, self.onLineWrap,  'Escape')]
            ),            
            ('Tools', 0,                                # [3.0] reorg, shortcuts
                 [('Info...',
                       0, self.onInfo,      '*-i'),     # file information
                  '----',
                  ('Popup',
                       0, self.onPopup,     '*-p'),     # [3.0] Tk=>Tolevel
                  ('Clone',
                       0, self.onClone,     '?-p'),     # Tk=>Tk, Top=>Top
                  '----',
                  ('Run Code...',
                       0, self.onRunCode,   '*-x')]     # a simple IDE option
            )
        ]


        #-------------------------------------------------------------------
        # Configure toolbar - a GuiMaker toolbar-def tree:
        #
        #   [(label, handler {packing-in-toolbar-arg}) | spacer]
        #
        # For spacer, '<...' = pack left, and '>...' = pack right.
        # Redundant with menus and accelerator-key combos, but
        # useful, especially on tablets (tiny menus, no keyboards).
        # This could use small GIF images, but keep it simple here.
        # It's also subjective and not user-configurable (today).
        #-------------------------------------------------------------------

        # user may configure the botton's font (None=system default)
        self.toolbarFont = Configs.get('toolbarFont', None)

        # user may pick fixed or expanding spacers (e.g., '<---')
        self.toolbarFixedLayout = Configs.get('toolbarFixedLayout', False)

        # avoid redundancy (or dict(side=X))
        packLeft  = {'side': LEFT}
        packRight = {'side': RIGHT}

        #--------------------------------------------------------------------
        # About using "portable" Unicode symbols for toolbar buttons:
        # Mac
        #   renders arrows, etc., great, though they're arguably obscure;
        #   probably, this could evolve to use totally incomprehensible GIFs...
        # Windows
        #   renders fat arrows unevenly across machines, even within same
        #   font family; abandoned arrows for ASCII characters on Windows;        
        # Linux
        #   buttons are huge and arrows renders too small: reuse Windows
        #   format to save space, and consider nuking some middle buttons;
        #   as is: uses wider init size, user can shrink to clip middles;
        #
        # UPDATE: the Linux toolbar width was resolved by using narrower 
        # Labels in guimaker, instead of Buttons; no need to make window 
        # wide, etc.  Other platforms could use Labels too, but it's not 
        # necessary: Mac Labels are spaced same, and shorter/rectangular.
        #
        # [4.0] toolbar was redesigned: dropped hieroglypichs, and bg, fg,
        # and pick buttons; and use real buttons instead of labels on Linux
        # (labels don't give feedback for taps, but could with extra code);
        # also wrestled with macOS to make buttons as close together and
        # reasonable as possible - see the code in ../Tools/guimaker.py.
        #--------------------------------------------------------------------

        # [4.0] redesign toolbar (drop hieroglyphics)
        """
        runcode = 'Run ⚙'
        popup   = 'Pop☝' if not RunningOnLinux else 'Pop ☝'   # need a space?
        info    = '  ⅈ  ' if RunningOnMacOS else 'Info'        # macOS fonts rule
        """

        runcode = 'Run'
        popup   = 'Pop'     # same on all platforms
        info    = 'Info'    # and move Info to last group

        
        # [4.0] redesign toolbar (drop bg, fg, pick: rare and in menus and Info)
        """
        if RunningOnWindows or RunningOnLinux:
            if True:
                bg, fg, inc, dec, pick, wrap = 'bf+-?↲'   # no spaces needed
            else: # alt
                bg, fg, inc, dec, pick, wrap = '↑↓↑↓?↲'   # but arrows too small
        else: # Mac
            if True:
                bg, fg, inc, dec, pick, wrap = ('⇧', '⇩', '⇧', '⇩', ' ? ', '⏎')
            else: # alt
                bg, fg, inc, dec, pick, wrap = [' %s ' % c for c in 'bf+-?'] + ['⏎']
        """

        inc, dec, wrap = '+', '-', 'Wrap'    # [4.0] drop hieroglyphics 
        if RunningOnWindows:                 # ok on macos too, but not linux
            inc, dec = ' + ', ' - '          # else too narror to tap on windows (only)        


        self.toolBar = [
            # right side 
            ('Quit',   self.onQuit,      packRight),  # first=rightmost
            ('Help',   self.onHelp,      packRight),  # pack 1st=clip last
            (info,     self.onInfo,      packRight),  # [3.0] added info, ⅈ?
            '>---',                                   # [4.0] move Info to last grp
            (popup,    self.onPopup,     packRight),  # [3.0] new window, ☝?
            (runcode,  self.onRunCode,   packRight),  # [3.0] added for fun, ⚒, ⚙?
            '>---',

            # left side
            ('Save',   self.onSave,      packLeft),
            ('Open',   self.onOpen,      packLeft),   # [3.0] added open
            '<---',                                   # [3.0] toolbar spacer
            ('Cut',    self.onCut,       packLeft),   
            ('Copy',   self.onCopy,      packLeft),
            ('Paste',  self.onPaste,     packLeft),
            '<---',
            ('Undo',   self.onUndo,      packLeft),   # [3.0] added for Mac,
            ('Redo',   self.onRedo,      packLeft),   # [3.0] pre menu acc
            '<---',
            ('Find',   self.onChange,    packLeft),   # [3.0] not onRefind
            ('Grep',   self.onGrep,      packLeft),   # [3.0] added for use
            '<---',
            ('Color',  self.onColorList, packLeft),   # [3.0] added these
           #(bg,       self.onPickBg,    packLeft),   # [4.0] cut; rarely used? 
           #(fg,       self.onPickFg,    packLeft),   # [4.0] cut bg
           #'<---',                                   # [4.0] cut fg
            ('Font',   self.onFontList,  packLeft),   # there's space...
            (inc,      self.onFontPlus,  packLeft),   # zoom in, ⇧ or ↑ 
            (dec,      self.onFontMinus, packLeft),   # zoom out, ⇩ or ↓
           #(pick,     self.onPickFont,  packLeft),   # [4.0] cut; Pick, ⇳, ⇵, …, ⌨
            (wrap,     self.onLineWrap,  packLeft)    # or Wrap, ⏎, ↲

            # then right-side spacer after Run
        ]

        # [4.0] now moot - Linux uses buttons too, not labels (see guimaker.py)
        if RunningOnLinux:
            pass  # no need to remove middle buttons: now uses Labels instead


    def accBindWidget(self):
        """
        --------------------------------------------------------------------
        [3.0] Run by GuiMaker.__init__, after start(), and before making
        menus and calling makeWidgets().  Return the widget on which menu
        accelerator key events are to be bound, if GuiMaker accelerators
        used.  Returning self.master may fail: this might be an embedded
        component instance, and type may impact firing of built-in bindings;
        Text widgets work, and GuiMaker replaces same-key default bindings.
        --------------------------------------------------------------------
        """
        text = Text(self)      # to be configured later: see makeWidgets
        self.text = text       # don't pack it here/yet: defer for clip order 
        return text            # intercepts accelerator events when has focus


    def makeWidgets(self):
        """
        --------------------------------------------------------------------
        Run by GuiMaker.__init__ after start() and menu/toolbar setup,
        but before TextEditor.__init__ is called from top-level classes.
        At this point, "self" is a GuiMaker mid-window Frame object,
        between the created menu and toolbar: build text area in middle.
        --------------------------------------------------------------------
        """
        name = Label(self, bg='black', fg='white')   # add below menu, above tool
        name.pack(side=TOP, fill=X)                  # menu/toolbars are packed
                                                     # GuiMaker frame packs itself
        vbar = Scrollbar(self)
        hbar = Scrollbar(self, orient='horizontal')
       #text = Text(self, padx=5, wrap='none')       # original coding
        text = self.text                             # [3.0] now made earlier
        text.config(padx=5, wrap='none')             # disable line wrapping
        text.config(undo=1, autoseparators=1)        # 2.0, default is 0, 1

        vbar.pack(side=RIGHT,  fill=Y)
        hbar.pack(side=BOTTOM, fill=X)                 # pack text last: clip 1st
        text.pack(side=TOP,    fill=BOTH, expand=YES)  # else sbars clipped

        text.config(yscrollcommand=vbar.set)     # call vbar.set on text move
        text.config(xscrollcommand=hbar.set)     # ditto for hbar.set
        vbar.config(command=text.yview)          # call text.yview on scroll move
        hbar.config(command=text.xview)          # or hbar['command']=text.xview

        # 2.0: apply user configs OR defaults
        startfont = Configs.get('font', self.fonts[0])          # var or list[0]
        startbg   = Configs.get('bg',   self.colors[0]['bg'])   # bg can be dark 
        startfg   = Configs.get('fg',   self.colors[0]['fg'])   # for cursor too
        text.config(font=startfont, bg=startbg, fg=startfg)

        # [3.0] cursor=fg, else can be lost in a dark bg
        text.config(insertbackground=startfg)

        # [3.0] auto add initial values to end of pick lists so selectable,
        # unless already present or added by a previously-created window
        if Configs.get('font'):
            if not startfont in self.fonts:
                self.fonts.append(startfont)         # self okay for class attr

        if Configs.get('fg') and Configs.get('bg'):  # from textConfigs.py
            initcols = dict(fg=startfg, bg=startbg)
            if not initcols in self.colors:          # dict '==' works in py3.X
                self.colors.append(initcols)  

        # uses tk default if unset: 24 lines x 80 chars
        if 'height' in Configs: text.config(height=Configs['height'])
        if 'width'  in Configs: text.config(width =Configs['width'])

        # [3.0] color cycling: auto set next window to next in color list;
        # this option applies to both top-level windows and components;
        if Configs.get('colorCycling', False):
            if TextEditor.namelessid > 0:            # all but first window (overload!)
                self.onColorList()                   # next fg/bg from list

        # [3.0] Escape key toggles line-wrapping (at char boundaries) on and off
        # this was adapted from the Run Code output window (it's that cool)
        self.textwrapped = 0    # now a 3-state toggle, start=none

        self.text = text        # now redundant but descriptive
        self.filelabel = name   # save widgets for changing


    def autoSaveLoop(self):
        """
        ------------------------------------------------------------------------
        [3.0] If configured to do so, every N minutes (3 by preset) save a
        copy of the current text in every open, changed, and unsaved PyEdit
        window or widget, to the configured self-cleaning auto-save folder.

        Usage notes:
        -- By design, this DOES NOT overwrite actual files being edited,
           but saves copies in a dedicated, separate folder.  It's just a
           last-resort backup in case of outright crash or operator mistake.
           Saved files will generally be useful immediately, or not at all.
        -- Cleans up auto-save files more than one week old (by default)
           to minimize clutter/space in the save dir
        -- Auto-save applies to both top-level (main and popup) windows, and
           embedded components in client program windoes (e.g., PyMailGUI
           View/Write mail text).  All PyEdit window types are auto-saved.
        -- Time between runs and retention days are now configurable, but
           their defaults are reasonable: 5 mins is roughly just 1 paragraph,
           and catastrophic data loss is likely known immediately or soon  
        -- To disable folder cleaning but leave auto-save enabled, set the
           days-retained to a very high number (but these files are temps);
           to disable auto-saves set its folder to None.

        Coding notes:
        -- This design explicitly rejects hidden files in the name of user
           friendliness.  While auto-save files could be stored alongside 
           the originals with leading "."s to make them hidden, this clutters
           user folders, obscures save files, and wouldn't help for saves
           of nameless files (not yet saved to a folder by the user).
        -- Uses either a known filename, or one generated for still-nameless
           windows.  In the former, the pathname (as much of it as possible)
           is appended to the filename to make same-named files unique in the
           auto-save folder, whether edited in the same or different sessions.
           In the latter, a window counter makes names unique within a PyEdit
           insteance, and a process id makes them unique across all instances.  
           Within an instance, the nameless-ID counter is incremented per edit
           window (main, popup, component) and New action (in any edit window).
        -- Uses general UTF-8 Unicode because a desired Unicode encoding
           may not yet be known or appropriate
        -- Runs just 1 auto-save timer loop per process, shared by any
           number of open windows in the session
        -- Tk's widget.after() method requires that widget not be destroyed
           before the timer expires, else no callback occurs (for proof, see
           docetc/examples/demo-poll-silent-exit-on-window-close.py)  Since
           "self" may be temporary (e.g., PyMailGUI components or popups), use
           tkinter._default_root, the implicit or explicit first-created Tk()
           that endures for the program, but fallback on "self" if it's None
           or unset ("self" is saved by tkinter callback even if its window
           is destroyed, so it can be used both for the timer handler and the
           after() widget).  See tkinter.NoDefaultRoot() for more on this story.
           This may preclude an all peer-level Tk() model: one window must be
           long-lived, and a "welcome" Tk() might open on every click on some.
        -- The ascii() calls for print() in announce() avoid exceptions when
           printing filenames with emojis on Mac OS X with no console (really)
        -- All Pyedit windows are automatically registered for auto-save on
           creation, and deregistered in their Text widgets' <Destroy> handler;
           registry is implemented as a simple global (class-level) list.
        -- Assumes CWD not changed if the save-path is relative to '.' (and
           the default is); now true, but Run Code's String mode made it iffy.
        -- TBD: this could be threaded if it ever becomes a noticable pause;
           unless you're running on a floppy drive, it's probably fine...
        
        [4.0] This now appends a process ID to the autosave filename to handle
        same file being open in > 1 process/instance.  This is rare and may not 
        help if the system recycles process IDs rapidly, but is better than not.
        NIT: process ID recycling can also be problematic for generated names of
        nameless files, but this seems too unlikely to be a concern in practice.
        NIT: this could add a timestamp to filenames to save multiple versions, 
        but this seems prone to generate LOTS of auto-save files over time.      
        ------------------------------------------------------------------------
        """
        import time
        helpfile   = 'README-autosaves.txt'                   # spared reaping
        savedir    = Configs.get('autoSaveToFolder')          # default=dir in '.'
        savemins   = Configs.get('autoSaveMinsTillRun',  5)   # 5 mins default
        retaindays = Configs.get('autoSaveDaysRetained', 7)   # 7 days default


        def savename_known(pathname, instancepid):
            r"""
            Convert a known pathname of a file to a name under
            which it may be saved in the auto-save folder.  This
            adds as much of the enclosing path as possible to make
            same-named files located at different paths distinct.

            This isn't foolproof, as the name's length is limited
            per supported-platform constraints (and filesystems:
            wikipedia.org/wiki/Comparison_of_file_systems#Limits),
            but the "correct" solution of storing full folder trees
            is slow to create and prune, and lousy on usability.

            The pathname is already absolute, as recorded by PyEdit.
            It must have only legal chars because it has been used,
            be we need to replace separators, and ':' for Windows.
            Truncating dirpath on the end seems just as likely to
            distinguish the file as truncating on the front (tbd).

            Caveat: though files save correctly here, they may result
            in paths exceeding Windows' length limits in some contexts.
            Run paths through os.path.abspath() and prefix with '\\?\'
            where needed, per the mergeall and ziptools programs' fixes.

            [4.0] Add process ID to filename per note above, as "BY".
            Change format of pid already added to nameless to match.
            Process ID is unique across machine, until recyled/reused.
            """
            namemax  = 255  # lowest common denominator
            filename = os.path.basename(pathname)
            dirpath  = os.path.dirname(pathname)
            dirpath  = dirpath.replace(os.sep, '_')
            dirpath  = dirpath.replace(':', '_')
            savename = f'{filename}--AT--{dirpath}--BY--{instancepid}.txt'
            if len(savename) > namemax:
                savename = savename[:namemax - 3] + '...'   # loses context
            return savename


        def savename_nameless(window, instancepid):
            """
            Create a unique save filename for changed text in an edit
            window that has not yet saved its text and thus has not 
            assigned it a real name in the filesystem.  The namelessid
            is a counter that is session unique: +1 per window and New.

            [4.0] Split off to function to call out symmetry to known.
            Process ID is unique across machine, until recyled/reused.
            """
            counter = window.namelessid
            savename = f'_nameless-{counter}--BY--{instancepid}.txt'
            return savename    # never too long

 
        def announce(*args):
            """
            Standard format with program name: may be embedded.
            Run all args through ascii() to avoid emoji errors. 
            """
            def isascii(text):
                try:    text.encode('ascii')
                except: return False
                else:   return True
            print('PyEdit auto-save',
                  *((arg if isascii(arg) else ascii(arg)) for arg in args))


        #--------------------------
        # autoSaveLoop starts here
        #--------------------------
        
        if not savedir:
            # None or missing: disabled - skip loop altogether
            return
        
        else:
            announce('running')

            # 1) cleanup auto-save folder items > N days old
            try:
                if os.path.exists(savedir):
                    for filename in os.listdir(savedir):
                        if filename == helpfile:
                            continue
                        pathname = os.path.join(savedir, filename)
                        modtime  = os.path.getmtime(pathname)   # epoch seconds
                        nowtime  = time.time()                  # ditto
                        dayssecs = retaindays * 24 * 60 * 60
                        if nowtime > modtime + dayssecs:
                            announce('pruning:', pathname)
                            try:
                                os.remove(pathname)
                            except Exception as why:
                                announce('skipped failed file:', why)
            except Exception as why:
                announce('reaper failed:', why)  # but continue here

            # 2) save copies of changed+unsaved files to auto-save folder
            windows = TextEditor.openwindows     # all open windows 
            changed = any(w.text_edit_modified() for w in windows)
            if not changed:
                pass   # no changes to save: go reschedule
            else:
                try:
                    if not os.path.exists(savedir):
                        os.mkdir(savedir)
                    for window in windows:
                        if window.text_edit_modified():
                            try:
                                instancepid = os.getpid()
                                knownname = window.getFileName()
                                if knownname:
                                    # use known file+path 
                                    filename = savename_known(knownname, instancepid)
                                else:
                                    # create a fake name
                                    filename = savename_nameless(window, instancepid)

                                # write to auto-save dir
                                filepath = os.path.join(savedir, filename)
                                fileobj  = open(filepath, 'w', encoding='utf8')
                                fileobj.write(window.getAllText())
                                fileobj.close()
                            except Exception as why:
                                announce('skipped file:', filename, why)
                            else:
                                announce('saved file:', filepath)
                except Exception as why:
                    announce('ended by exception:', why)    # but continue GUI

            # 3) reschedule for next run
            announce('finished')
            try:
                # use a window that endures
                import tkinter                              # app's Tk root win?
                topwin = getattr(tkinter, '_default_root', None) 
                regwin = topwin or self                     # or resort to self
                msecstimer = savemins * 60 * 1000           # N minutes of msecs
                regwin.after(msecstimer, self.autoSaveLoop) # go again in N mins
            except Exception as why:
                announce('reschedule failed:', why)         # probably never, but...
            # back to tk event loop




    ############################################################################
    # File menu commands
    ############################################################################


    def fixTkBMP_FileDialogs(self, dialogobj):
        """
        --------------------------------------------------------------------
        [3.0] for file Open and SaveAs dialogs, pass initialfile=None to 
        avoid tkinter errors if a prior call selected and cached a filename
        with a non-BMP Unicode character, and also pass initialdir=None if
        the prior pathname pick had such text; else, a saved file/dir name
        with emojis causes the dialog to fail on errors when run by Python;

        this disables highlighting of the prior file and/or starting in 
        the prior dir, but we avoid this in normal cases when the prior 
        choices were all BMP, and the effect spans just one call (the next 
        open/save can use a prior valid initialfile and initialdir again);
        Mac SaveAs dialogs prefill prior filename instead of highlighting,
        and uses "Untitled" if intitaldir=None, but is otherwise the same;

        this is a broad tkinter+Tk file-dialog issue: tkinter saves the 
        prior choice, and Tk supports only BMP text; fixed locally here,
        but _every_ tkinter dialog object (not func call) has the issue;
        --------------------------------------------------------------------
        """
        priorfile = dialogobj.options.get('initialfile', '')
        priorpath = dialogobj.options.get('initialdir', '')
     
        if isNonBMP(priorpath):
            # forget both for this call only
            return dict(initialdir=None, initialfile=None)

        elif isNonBMP(priorfile):
            # forget file for this call only, use path
            return dict(initialfile=None)

        else:
            # use both prior file and path for this call
            return dict()


    def my_askopenfilename(self):
        """
        --------------------------------------------------------------------
        use dialog objects that remember last result dir and file
        [3.0] add custom title text, and specialize its arg name for Mac
        [3.0] filetypes '*.*' fails on Mac: non-matches grey, unselectable
        [3.0] use parent=self so root not raised above subject window;
        this also triggers slide-down sheet style on Mac per its norms;
        [3.0] fix emojis in prior choice via fixTkBMP_FileDialogs args;  

        [4.0] the macOS system file dialogs used by Tk can and do crash:
        use a custom alternative to macOS dialog if config setting on;
        see the save dialog below for more info on this option;

        [4.0] (info) for both open and save native dialogs, passing 'parent' 
        in 'dlgargs' makes tkinter use that as the master on which to run 
        the Tk dialog call, but passing 'parent' later to the show() method
        makes tkinter create and destory a _temp_ Tk() for the call's master 
        instead.  The latter seems perilous but is REQUIRED here as coded:
        because the Open and SaveAs dialog objects are class data shared by
        all windows in the process, setting their master to a current window
        which may be closed before the next dialog show() can trigger errors.
        Recoding to use per-instance data means nav restarts in each window.

        [4.0] (info): for native open/save dialogs, the dialog objects are 
        thin wrappers that call a Tk command, so there is no object on which 
        to run a method to limit dialog size on Android.  Since we are now
        presetting this config to skip native and go custom, it's mostly moot.
        --------------------------------------------------------------------
        """
        startfiledir = self.startfiledir    # config or tweaked per platform in class 

        # [4.0] custom dialog: macOS workaround and Android option, per config
        if Configs.get('opensUseCustomFileDialog', False):
            dialog = LoadFileDialog(self)                   # pass start else py's cwd

            fixAppleMenuBarChild(dialog.top)                # do the macos menu thingy
            self.limitWindowToScreen(dialog.top)            # limit size, don't expand
            dialog.top.resizable(width=True, height=True)   # else fixed on macos; why?

            choice = dialog.go(startfiledir, key='all')     # from tkinter.filedialog
            return choice

        # native dialog: make shared dialog object first time
        if not self.openDialog:
            title = self.appname + ': Open File'
            if RunningOnMacOS:
                dlgargs = dict(
                    #parent=self.master,              # don't lift master, use Mac sheet
                    message=title,                    # macOS open ignores 'title' (?)
                    #title=title,                     # => now displays both: use one
                    initialdir=startfiledir,
                    filetypes=self.filetypes,         # macOS fails on 'filetypes' (?)
                    )                                 # => now usable via Options button
            else:
                dlgargs = dict(
                    #parent=self.master,              # don't lift master root window
                    title=title,                      # Windows+Linux use title
                    initialdir=startfiledir,          # Windows fails on 'message'
                    filetypes=self.filetypes,         # Windows shows .xxx as *.xxx
                    )
            TextEditor.openDialog = Open(**dlgargs)   # shared across all windows

        # disable prior file/path name picks having emojis: kills dialog ([4.0] moot/empty)
        fixBMPargs = self.fixTkBMP_FileDialogs(self.openDialog)

        # run the dialog, restore focus
        choice = self.openDialog.show(
                     parent=self.master,    # if here, won't set Dialog.master: temp root
                     **fixBMPargs)          # avoid non-BMP Unicode failures ([4.0] moot)

        dlgRefocus(self)                    # [3.0] else Mac needs click if Cancel
        return choice                       # empty string or selected pathname 


    def my_asksaveasfilename(self):
        """
        --------------------------------------------------------------------
        use dialog objects that remember last result dir and file
        [3.0] add custom title text (no need to specialize arg for Mac);
        [3.0] use parent=self so root not raised above subject window;
        [3.0] fix emojis in prior choice via fixTkBMP_FileDialogs args;  

        [4.0] SaveAs() is intermitently crashing on macOS due to sandboxing:
        use a custom tkinter alternative to macOS dialog if user-config setting 
        on (it's limited but doesn't crash); see textConfigs.py for more info;

        tkinter alternative may be better than native on Android too: preset;
        update: allows open and save to select the custom dialog separately:
        this can be used to address the save-only crash on macOS - only;

        also tried: neither setting start dir to Documents nor giving Terminal 
        macOS's Full Disk Access permission in privacy settings avoided crashes;
        per below, every Tk/tkinter option was tried as a fix in vain... punt;
        --------------------------------------------------------------------
        """
        startfiledir = self.startfiledir    # config or tweaked per platform in class 

        # [4.0] custom dialog: macOS workaround and Android option, per config
        if Configs.get('savesUseCustomFileDialog', False):
            dialog = SaveFileDialog(self)                      # pass start else py's cwd

            fixAppleMenuBarChild(dialog.top)                   # do the macos menu thingy
            self.limitWindowToScreen(dialog.top)               # limit size, don't expand
            dialog.top.resizable(width=True, height=True)      # else fixed on macos; why?

            choice = dialog.go(startfiledir, key='all')        # from tkinter.filedialog
            return choice

        # native dialog: make shared dialog object first time
        if not self.saveDialog:
            title = self.appname + ': Save File' 
            if RunningOnMacOS:
                dlgargs = dict(
 
                    #parent=self.text,                 # moot here, need for nonmacs
                    #parent=self,                      # doesn't matter
                    #initialdir=self.startfiledir,     # before moved tweaks to class
                    #initialdir=None,                  # no effect on crashes
                    #initialfile=None,                 # no effect on crashes

                    #parent=self.master,               # don't lift master, use Mac sheet
                    defaultextension='.txt',           # but ignored on macos
                    message=title,                     # but save uses title on all
                    initialdir=startfiledir,           # is ~ security the bug? (no)
                    filetypes=self.filetypes,          # filetypes adds a pause!?
                    )                                  # greyedout+selectable=>now ok
            else:        
                dlgargs = dict(
                    #parent=self.master,               # don't lift master root window
                    defaultextension='.txt',
                    title=title, 
                    initialdir=startfiledir,
                    filetypes=self.filetypes,
                    )
            TextEditor.saveDialog = SaveAs(**dlgargs)    # shared across all windows

        # disable prior file/path name picks having emojis: kills dialog ([4.0] moot/empty)
        fixBMPargs = self.fixTkBMP_FileDialogs(self.saveDialog)

        # run the dialog, restore focus
        choice = self.saveDialog.show(
                     #parent=self.text,     # no effect on crashes
                     parent=self.master,    # if here, won't set Dialog.master: temp root
                     **fixBMPargs)          # avoid non-BMP Unicode failures ([4.0] moot)

        dlgRefocus(self)                    # [3.0] else Mac needs click if Cancel
        #self.text.focus_set()              # no effect on macos crashes
        return choice                       # empty string or selected pathname 


    def findTopLevel(self):
        """
        --------------------------------------------------------------------
        [3.0] climb tkinter parentage chain to containing window;
        used to lift the top-level window containing TextEditor self,
        whether self is a standalone window or a nested component;
        --------------------------------------------------------------------
        """
        window = self.master
        while window and not isinstance(window, (Tk, Toplevel)):
            window = window.master
        return window


    @staticmethod
    def liftWindows(windows):
        """
        --------------------------------------------------------------------
        [3.0] lift the windows containing all the open PyEdit editor
        widgets in list 'windows' to the top of the display, and set
        focus on their text;  initially used by both Open (where
        'windows' is widgets where a file is already open) and Quit
        (where 'windows' is widgets with unsaved changes);  static,
        because also called by PyMailGUI's main list window's Quit;
        --------------------------------------------------------------------
        """
        for win in windows:
            toplevel = win.findTopLevel()
            if toplevel.state() == 'iconic':   # raise window if withdrawn
                toplevel.deiconify()           # then lift above others
            toplevel.lift()                    # may be > 1 changed/reopened:
            win.text.focus_set()               # the last will be activated


    """
    =============================================================================
    [4.0] Cross-process already-open-file detection, all platforms.

    THE PROBLEM:

    The launcher in macOS's UI (LaunchServices) provides a standard and
    default single-instance app model, which sends an open/reopen message
    to the app's same single instance (really, process) every time a file
    is opened, by Finder click or drag, "open" command line, or other.  
    This makes it easy to detect an already-open file on macOS: simply 
    check the currently open filename of all the open editor windows
    recorded on a global list.  This also makes it easy to raise the 
    file's window if the users cancels the open: just call a GUI lib 
    method for the window.  These in-process steps are already run.

    Unfortunately, Windows and other non-macOS hosts do not support 
    single-instance apps in a way that can be readily used in a Python
    program.  Instead, each file open outside the program spawns a new 
    independent instance of the program.  Hence, on non-macOS hosts, 
    the former in-process already-open test works for files opened in 
    the app itself (e.g., by its Open) but fails for files opened in
    the host's UI (e.g., by a click in Explorer).  This is obviously 
    subpar when editing many files in many windows and/or desktops - 
    it's far too easy to change a file incompatibly in separate windows.  

    Though atypical, even macOS will happily spawn multiple instances 
    of an app when programs are started from a Terminal command line 
    that does not use a default "open" command.  "python3 textEditor.py" 
    with or without a filename starts an independent instance/process,
    as does "open -n" with a file name, app name, or both.  In such 
    cases, the former in-process already-open test won't suffice on
    macOS either, and again a file may be opened redundantly with 
    Finder clicks or drags or other command lines. 

    Hence, this is a cross-platform defect, and a big one: allowing files
    to be changed differently in different app instances without a user 
    alert can easily lead to data loss.  While users can avoid the risk
    by adopting a manual single-instance usage mode via the GUI's Popup 
    and Open, this is much less convenient than file-explorer clicks.

    THE SOLUTION:

    A networking IPC fix (e.g., a UDP socket muticast messaging probe for
    app instances having the file open) was initially explored but abandoned.
    Such schemes seem highly complex and brittle, and in worst cases require 
    users to configure a socket port number to avoid clashes with other 
    programs (why has this socket design defect never been addressed?).

    Instead, a lockfile fix was developed to detect open files across 
    all PyEdit processes (instances).  Opens run this test after, and in 
    addition to, the former test for in-process open files, and the two 
    test's results are combined into a single user ask.  The in-process 
    test is still separate because it can raise an already-open file; the
    cross-process test is broader and could subsume the in-process test, 
    but it cannot raise the open file (again sans IPC not used) and simply
    verifies the open with the user; in-process still raises as before.

    To implement cross-process tests, this program stores normally-hidden
    ".<filename>.<pid>.lck" files alongside (in the same directory as) each
    file that is open in a PyEdit process.  These files are created on all
    opens, and deleted on all file switches, window closes, and program exits.
    There can be more than one lockfile alongside a given file if the file is 
    open in more than one process, and a given process may have lockfiles for
    more than one file if Popup (or other) spawns new edit windows.

    On opens, the target file's lockfiles are all collected by globbing, 
    their filenames are parsed to extract the list of process IDs (pids) 
    of instances having the file open, and lockfile creation times are 
    collected from file creation times embdded in the files themselves.
    The locks' pids are then checked for "aliveness" by presence in 
    active-process lists using the portable third-party psutil lib, and
    lockfile validity is verified by comparing process and lockfile 
    creation times.  If a lockfile's pid is both alive and valid, the
    file is open in another PyEdit and the user must validate the open.

    SUBTLE BITS:

    The cross-process already-open test has some subtle requirements:       

    In-process opens must write lockfiles too
      Lockfiles must also be written for opens in extra windows within the 
      same process, even though these are managed by the in-process test.
      Other PyEdit processes need to know all files opened by any instance.

    Multiple processes require multiple lockfiles
      Because a given file may be open in multiple processes if reopens have been 
      allowed by the user, a single lockfile won't suffice, even one that records
      the locker's pid.  Instead, lockfiles embed the locking process in their 
      filenames, and possibly many are present and collected by the cross-process
      test for a given file.  A given file may also be open multiple times in 
      the same process (in multiple windows) though any one lockfile suffices for
      cross-process tests, and lockfiles are unused+harmless for in-process tests.

    Zombie lockfiles for dead processes
      It's possible that a PyEdit instance/process may die before it can delete
      its lockfiles (e.g., on program or system crash).  In this case, processes
      leave zombie lockfiles that must be ruled out and removed by the cross-process 
      test, else files may be wrongly reported as already open.  For this:
  
      - It's not enough to simply check the lockfile pids against the list of 
        all active processes (psutil.pids()) because pids are recycled and can 
        be reused for unrelated processes in the future.  Likely over time.

      - It's also not enough to narrow the test to pids for processes that are 
        PyEdit (e.g., by parsing psutil's process.info['cmdline']) because 
        another PyEdit instance may have been started after a former crash and
        been reassigned the same pid as the crasher.  Rare, but possible.

      Instead, zombie lockfiles are detected by comparing the lockfile's creation 
      time (manually saved in the lockfile from time.time(): see next item) with 
      its process's starttime (obtained from psutil's process.create_time()), both
      of which are assumed to be a comparable UTC-time float since the Unix epoch
      (Jan-1-1970).  If the process starttime is newer than the file's createtime
      then the process could not have created the file, the creator must have 
      crashed prior to lockfile deletion, and the lockfile is ignored and removed. 
      Pids could also be checked for PyEdit relevance, but this is not necessary.

    Saving create times to sidestep filesystems
      Filesystem modtimes have varying resolution (e.g., 2 seconds for FAT32), 
      and some filesystems (e.g., FAT32) record a "local" time instead of UTC's
      time-since-epoch time that may not be directly comparable after DST and
      timezone switches.  To finesse such issues, this program writes the UTC 
      time at lockfile creation (time.time()) into the lockfile itself and reads
      it back later, rather than relying on filesystem results (available from 
      os.path.getmtime(path)).  This works here because lockfiles have no user
      content with which to collide.

      This also works around the fact that filesystem createtime is true creation 
      time on Windws, but not on Unix (where it's meatadata modtime), and this 
      may vary per filesystem, not just platform.  Since lockfiles are simply 
      created once, modtime would suffice everywhere but suffers from the 
      interoperability issues of the prior paragraph.

    Saves must check for open files (both in- and cross-process) too
      The first time a file is saved in a window, we must also check if
      the file is open in the same or other process because this effectively
      sets the file to be open - as if it had been opened with Open.  Hence,
      the aready-open test must be generalized for both onOpen and onSave.
      The prior version of Pyedit did not run its in-process test for saves.
      In terms of UI, Open (also run for initial args and Grep matches) must 
      test for and write lockfiles; Save must do the same unless the filename 
      is not new; and New must delete lockfiles (unless open in other windows).

    CAVEATS:

    The cross-process scheme comes with some known potential downsides that 
    were deemed valid trade-offs to negate the high risk of modding files 
    differently across processes:

    Lockfiles are ugly
      Lockfiles are not nice to see for users that enable hidden-file views, 
      but there's no good alternative.  They also may or may not be propagated 
      by content syncs, but this seems irrelevant to use by this program.

    Race conditions
      Like networking alternatives, this cross-process scheme comes with built-in 
      race-condition perils: lockfiles may be written or removed while an 
      already-open test is in progress (due to highly unlikely+fast UI action),
      and processes may die at any point during this test (due to unlikely but 
      unpredictable system action).  The risks of these are remote and minimal.

    Zombie lockfile errors
      Detecting these files is complex and thus error-prone, though this crops
      up only when PyEdit crashes before lockfile deletion - a rare event.
    =============================================================================
    """
  

    def is_last_open_in_process(self, filename=None):
        """
        --------------------------------------------------------------------
        Don't delete lockfile if file open in any other windows in this process,
        else lockfile still needed for cross-process test: check if last/only.
        --------------------------------------------------------------------
        """

        filename = filename or self.getFileName()
        if filename != None:
            openinwindows = [w for w in TextEditor.openwindows if w.currfile == filename]
            if len(openinwindows) > 1:
                trace('Skipping lockfile delete for > 1 open in process:', filename)
                return False
        return True


    def delete_openfile_lockfile(self, filename=None, lockpid=None):
        """
        --------------------------------------------------------------------
        On file switches, single window closes, all windows on program exits.
        Can be used for a known file and pid, else use self's file and pid.
        --------------------------------------------------------------------
        """

        filename = filename or self.getFileName()    # already abspath() = absolute+normalized
        if filename == None:                         # abspath() runs normpath() for mixed slashes
            return                                   # None means no external file open in window
        else:
            lockpid = lockpid or os.getpid()
            filepath, filebase = os.path.split(filename)
            lockname = f'{filepath}{os.sep}.{filebase}.{lockpid}.lck'
            
            if not os.path.exists(lockname):         # skip delete if no lockfile is present
                return                               # for naive calls in all switches/closes
            else:
                try:
                    os.remove(lockname)
                    trace(f'Deleted lockfile for pid={lockpid} file={filename}')
                except:
                    print('Warning: error removing open-file lockfile for', filename)
                    print('This may prevent opens while this process still runs')


    def create_openfile_lockfile(self, filename=None, lockpid=None):
        """
        --------------------------------------------------------------------
        On open finish, create lockfile with pid in name and createtime in file.
        Can be used for a known file and pid, else use self's file and pid.
        Caller must catch excepts raised here: file opens/writes _can_ fail.
        --------------------------------------------------------------------
        """

        filename = filename or self.getFileName()
        if filename == None:
            return
        else:
            lockpid = lockpid or os.getpid()
            filepath, filebase = os.path.split(filename)
            lockname = f'{filepath}{os.sep}.{filebase}.{lockpid}.lck'    # PID in filename
            lockfile = open(lockname, 'w')                               # UTC ctime in file
            lockfile.write(f'{time.time():.2f}')                         # (or use pickle)
            lockfile.close()                                             # ASCII-only text


    def collect_openfile_lockfiles(self, filename):
        """
        --------------------------------------------------------------------
        On opens, collect info for all lockfiles for absolute-path filename.
        Must pass a known filename for the filesystem glob, returns 3-tuple.
        Caller must catch excepts raised here: file opens/reads _can_ fail.
        --------------------------------------------------------------------
        """

        filepath, filebase = os.path.split(filename)
        lockspatt = f'{filepath}{os.sep}.{filebase}.*.lck'
        locknames = glob.glob(lockspatt)
        lockinfos = []
        for lockname in locknames:
             lockpid   = int(lockname.rsplit('.', 2)[-2])                # PID in filename
             lockfile  = open(lockname)                                  # UTC ctime in file
             lockctime = float(lockfile.read())
             lockfile.close()
             lockinfos.append((lockpid, lockctime, lockname))
        return lockinfos


    def crossProcessAlreadyOpenTest(self, filename):
        """
        --------------------------------------------------------------------
        On Open, check cross-platform already-opens after in-process check.
        Returns true if should ask and (pass | open and then lockfile write).
        Actual return value is number of other process with the file open.
        --------------------------------------------------------------------
        """

        mypid = os.getpid()                                             # this pyedit's pid
        filename = os.path.abspath(filename)                            # just to be sure
        try:
            lockinfos = self.collect_openfile_lockfiles(filename)       # glob: pids+ctimes
        except:
            print('Error getting lockfiles: cross-process already-open test skipped')
            return True
        trace(f'Cross-process test {lockinfos=}')

        lockpidsAliveAndValid = 0
        for (lockpid, lockctime, lockname) in lockinfos:                # test file's lockfiles

            if lockpid == mypid:                                        # in-process test saw
                pass                                                    # redundant here: skip

            elif not psutil.pid_exists(lockpid):                        # process not alive
                trace('Zombie lockfile for dead pid:', lockname)        # pyeditmust have crashed
                try:                                                    # cleanup it lockfile now
                    self.delete_openfile_lockfile(filename, lockpid)
                    #trace('Deleted zombie lockfile:', lockpid)
                except:
                    print('Warning: error removing zombie lockfile for', filename)

            else:                                                       # pid's process alive
                try:
                    process = psutil.Process(lockpid)                   # can fail if killed
                except:                                                 # skip: it just died
                    continue               

                if process.create_time() > lockctime:                   # process newer than lock
                    trace('Zombie lockfile for live pid:', lockname)    # pid must be recycled,
                    try:                                                # for new pyedit or other 
                        self.delete_openfile_lockfile(filename, lockpid)
                        #trace('Deleted zombie lockfile:', lockpid)
                    except:
                        print('Warning: error removing zombie lockfile for', filename)

                else:                                                   # lock newer than process
                    lockpidsAliveAndValid += 1                          # locker is alive+valid

        if lockpidsAliveAndValid > 0:
            trace(f'File open in {lockpidsAliveAndValid} process(es)')

        return lockpidsAliveAndValid    # True = open file and write lockfile


    def inProcessAlreadyOpenTest(self, filename):
        """
        --------------------------------------------------------------------
        [4.0] Split off to a method for symemtry with new cross-process test.
        Returns true if should ask and (raise | open and then lockfile write).
        Actual return value is list of windows with file open, for raise.
        [3.0] The original coding os already-open tests - in-process only.
        --------------------------------------------------------------------
        """

        match = os.path.abspath(filename)
        openinwindows = [w for w in TextEditor.openwindows if w.currfile == match]
        return openinwindows


    def alreadyOpenTest(self, filename, oplabel='Reopen'):
        """
        --------------------------------------------------------------------
        [4.0] Validate already-open files, return True if caller should proceed.
        Factored into common method, because now also run for Save, not just for
        Open (former usage).  For Save, file-chooser dialog will first ask about
        overwriting existing files, then this will ask about opening a file already
        open in same or other process.  This happens in Save when first saving text
        to a filename; Save skips this if filename is unchanged.

        Older docs:
        [3.0] same-process already-open test: raise window, or let user reopen;
        this applies to nested components too, and nameless windows are moot;
        TBD: don't ask if (len(openwindows) == 1 and openwindow[0] == self)?;
        [4.0] now combined with new cross-process test for a single ask + ?raise;
        cross-platform test skips this pyedit's process id: caught by in-process;
        --------------------------------------------------------------------
        """
        
        inProcessAlreadyOpen    = self.inProcessAlreadyOpenTest(filename)       # [windows]
        crossProcessAlreadyOpen = self.crossProcessAlreadyOpenTest(filename)    # counter

        if not (inProcessAlreadyOpen or crossProcessAlreadyOpen):
            return True
        else:
            asker = ('in the same and other PyEdits'
                         if inProcessAlreadyOpen and crossProcessAlreadyOpen else
                     'in the same PyEdit'
                         if inProcessAlreadyOpen else
                     'in another PyEdit' 
                         if crossProcessAlreadyOpen == 1 else
                     'in other PyEdits')

            self.update()
            if my_askyesno(self, 'Open', f'File already open {asker}.\n\n{oplabel} anyhow?'):
                # continue with duplicate open, and [4.0] lockfile write
                return True
            else:
                if inProcessAlreadyOpen:
                    # raise already-open instance(s)
                    self.liftWindows(inProcessAlreadyOpen)    # may be > 1 if reopened
                return False                                  # some callers may onQuit() now            
       

    @modalMenuAction
    def onOpen(self, loadFirst='', loadEncode=''):
        r"""
        ----------------------------------------------------------------------
        Run for __init__ with filename, Open in menus and toolbars, Grep 
        match-list clicks, amd macOS UI OpenDoc events for single instance.

        2.1: total rewrite for Unicode support; open in text mode with 
        an encoding passed in, input from the user, in textconfig, or  
        platform default, or open as binary bytes for arbitrary Unicode
        encodings as last resort and drop \r in Windows end-lines if 
        present so text displays normally; content fetches are returned
        as str, so need to  encode on saves: keep encoding used here;

        tests if file is okay ahead of time to try to avoid opens;
        this code could also load and manually decode bytes to str to
        avoid multiple open attempts (like Save ahead), but it is
        unlikely that this code will wind up trying all its cases;

        encoding behavior is configurable in the local textConfig.py:
        1) tries known type first if passed in by client (email charsets)
        2) if opensAskUser True, try user input next (prefill with defaults)
        3) if opensEncoding nonempty, try this encoding next: 'latin-1', etc.
        4) tries sys.getdefaultencoding() platform default next (but 4.0 mods)
        5) uses binary mode bytes and Tk policy as the last resort

        end-lines: because the 'newline' parameter is not passed to open(),
        this code is able to read files having any end-line format (DOS \r\n
        or Unix \n), and receives its read results in universal \n format
        in text mode 'r' (binary-mode reads do not translate end-lines);

        file closes: as coded, this relies on the fact that CPython file 
        objects automatically close() themselves when garbage collected, 
        which happens here when expression temporaries are discarded;

        [3.0] add already-open test/raise, and return True if and only if
        a file was opened (else None) to avoid a bad line# error in Grep;

        [3.0] warn the user about replacements and destructive saves if the
        file content has non-BMP "emoji" chracters; Tk ~8.6 doesn't support;

        [4.0] The platform default encoding for open step #4 is NOT the
        call formerly used here, sys.getdefaultencoding().  Until Python 3.15
        enables UTF-8 mode everywhere, it's locale.getpreferredencoding(False)
        (and NOT locale.getencoding(), which is ignorant UTF-8 mode settings 
        on the host).  In Py 3.X, sys.getdefaultencoding() is the default for
        string methods and always UTF-8.  For this tangled tale, see LP6E Ch37.
        Popup messages now also split filename to separate line for clarity.
        ----------------------------------------------------------------------
        """

        # [4.0] replace sys.getdefaultencoding() (till py3.15 UTF-8 mode)
        platformDefaultEncoding = locale.getpreferredencoding(False)

        if self.text_edit_modified():    # 2.0
            if not my_askyesno(self, 'Open', 'Text has changed: discard changes?'):
                return

        filename = loadFirst or self.my_askopenfilename()
        if not filename: 
            return
        
        if not os.path.isfile(filename):    # [3.0] links to files are okay too
            my_showerror(self, 'Open', 'Could not open file:\n' + filename)  # [4.0]
            return

        # verify open if already open in this [3.0] or other [4.0] process 
        if not self.alreadyOpenTest(filename):
            return    # and raise open windows if in same process          

        # try Unicode encoding sources for opens in turn...

        # 1) try known encoding if passed and accurate (e.g., email)
        text = None     # empty file = '' = False: test for None!
        if loadEncode:
            try:
                text = open(filename, 'r', encoding=loadEncode).read()
                self.knownEncoding = loadEncode
            except (UnicodeError, LookupError, IOError):         # lookup: bad name
                pass                                             # text is still None

        # 2) try user input, prefill with next choice as default
        askuser = None  # [4.0]
        if text == None and self.opensAskUser:
            self.update()  # else dialog doesn't appear in rare cases
            askuser = my_askstring(self, 'Open',
                                   'Enter Unicode encoding for open',
                                   initialvalue=(self.opensEncoding or 
                                                 platformDefaultEncoding or ''))
            self.text.focus()  # else must click (now auto)
            if askuser:
                try:
                    text = open(filename, 'r', encoding=askuser).read()
                    self.knownEncoding = askuser
                except (UnicodeError, LookupError, IOError):
                    pass
            # else continue to next options on Cancel (like onSave, don't return)

            # [4.0] Info: Cancel press returns None for askuser (empty string is
            # '', also false).  It seems odd to continue after Cancel, but need 
            # to move on to try next options.  Hence, Cancel is really a Skip,
            # but changing the button's text in tkinter's dialogs is painful.

        # 3) try config file (or before ask?); [4.0] don't try twice if was user input
        if text == None and self.opensEncoding and self.opensEncoding != askuser:
            try:
                text = open(filename, 'r', encoding=self.opensEncoding).read()
                self.knownEncoding = self.opensEncoding
            except (UnicodeError, LookupError, IOError):
                pass

        # 4) try platform default (utf-8 on windows?); [4.0] this wasn't right!
        if text == None:
            try:
                text = open(filename, 'r', encoding=platformDefaultEncoding).read()
                self.knownEncoding = platformDefaultEncoding
            except (UnicodeError, LookupError, IOError):
                pass

        # 5) last resort: use binary bytes and rely on Tk to decode
        if text == None:
            try:
                text = open(filename, 'rb').read()         # bytes for Unicode
                text = text.replace(b'\r\n', b'\n')        # for display, saves
                self.knownEncoding = None
            except IOError:
                pass

        if text == None:
            my_showerror(self, 'Open', 'Could not decode and open file:\n' + filename)  # [4.0]
        else:
            priorfile = self.getFileName()    # [4.0] for lockfile delete
            self.setAllText(text)
            self.setFileName(filename)
            self.text.edit_reset()             # 2.0: clear undo/redo stks
            self.text.edit_modified(0)         # 2.0: clear modified flag

            # [3.0] raise window above root, focus text
            # no longer needed if parent=self for dialogs
            """
            self.update()
            toplevel = self.findTopLevel()     # or self.liftWindows([self])
            toplevel.lift()                    # update(), else root on top
            self.text.focus_set()              # focus, else user must click
            """

            # [3.0] warn user about potential for destructive saves;
            # could user showwarning, but not used, and same on Mac;
            # could do this in setAllText, but that's only used here,
            # and for PyMailGUI's non-file raw text and View windows
            # (PyMailGUI's text-part popups will wind up coming here);
            # [4.0] now moot and skipped in Tks that support emojis+;
            # [4.0] add \n\n for clips on Android - where not yet moot;
            
            if isinstance(text, str) and isNonBMP(text):   # bytes is right out!  
                self.update()   # show text first
                my_showinfo(self, 'Open', 
                    'Caution: this file contains non-BMP Unicode characters.\n\n'
                    'These characters have been replaced for display.\n\n'
                    'Saving this text to a file may result in loss of the '
                    'characters replaced.\n\n'
                    'See the User Guide\'s "About emojis" for details.') 

            # [4.0] remove prior file's lockfile, but iff there is a prior file 
            # and lockfile, and no other windows have file open in this process

            if self.is_last_open_in_process(priorfile):
                self.delete_openfile_lockfile(priorfile)

            # [4.0] write new lockfile now, after open is fully completed.
            # Lockfile made for newly opened file, my pid, and current time.
            # This happens in all cases, whether in-|cross-process dup or not.
            # In-process dup will overwrite former lockfile for this pid,
            # but we need just one per file for the cross-platform test.

            try:                            
                self.create_openfile_lockfile(filename)
                trace(f'Wrote lockfile for {filename=}')
            except:
                print(f'Error writing lockfile for {filename=}')
                print('Already-open tests will not be used for this file')
                
            return True   # iff actually opened a file (else returns None)
            

    def onSave(self):
        """
        save text to file (currfile may be None if first save);
        no need for @modalMenuAcion here: onSaveAs already does,
        and would need to allowModals() to clear lock if used;
        """
        self.onSaveAs(self.currfile)


    @modalMenuAction
    def onSaveAs(self, forcefile=None):
        r"""
        ----------------------------------------------------------------------
        2.1: total rewrite for Unicode support: Text widget content is
        always returned as a str, so we must deal with encodings to save
        to a file here, regardless of open mode of the output file (binary
        requires bytes, and text must encode); tries the encoding used
        when opened or saved (if known), user input, config file setting,
        and platform default last; most users can use platform default; 

        retains successful encoding name here for next save, because this
        may be the first Save after New or a manual text insertion;  Save
        and SaveAs may both use last known encoding, per config file (it
        probably should be used for Save, but SaveAs usage is unclear);
        gui prompts are prefilled with the known encoding if there is one;
        
        does manual text.encode() to avoid creating file too soon; text
        mode files perform platform-specific end-line conversion: Windows
        \r is dropped if present on open() by text mode (auto) and binary
        mode (manually); if content is inserted into the widget manually,
        inserter must delete \r else duplicates here; knownEncoding=None
        before first Open or Save, after New, and if binary Open;

        encoding behavior is configurable in the local textConfig.py:
        1) if savesUseKnownEncoding > 0, try encoding from last open or save
        2) if savesAskUser True, try user input next (prefill with known?)
        3) if savesEncoding nonempty, try this encoding next: 'utf-8', etc
        4) tries sys.getdefaultencoding() as a last resort (but 4.0 mods)

        end-lines: because the 'newline' parameter is not passed to open(),
        this code always writes files using the hosting platform's end-line
        format (all \n are translated to os.linesep: DOS \r\n or Unix \n);
        see the utility fixeoln.py in tools/ if this is not desireable;

        [4.0] Run in-process and cross-process already-open tests here too:
        a Save for new text is essentially like an Open in terms of the 
        already-open test, and user should be alerted.  But skip this test
        if filename has not changed: already tested on first save.

        [4.0] The platform default encoding for save step #4 is NOT the
        call formerly used here, sys.getdefaultencoding().  Until Python 3.15
        enables UTF-8 mode everywhere, it's locale.getpreferredencoding(False)
        (and NOT locale.getencoding(), which is ignorant UTF-8 mode settings 
        on the host).  In Py 3.X, sys.getdefaultencoding() is the default for
        string methods and always UTF-8.  For this tangled tale, see LP6E Ch37.
        ----------------------------------------------------------------------
        """

        # [4.0] replace sys.getdefaultencoding() (till py3.15 UTF-8 mode)
        platformDefaultEncoding = locale.getpreferredencoding(False)

        filename = forcefile or self.my_asksaveasfilename()
        if not filename:
            return

        # verify save if already open in this [3.0] or other [4.0] process 
        priorfile = self.getFileName()      # [4.0] poss None, also for lockfile ahead
        if filename != priorfile:
            if not self.alreadyOpenTest(filename, 'Save'):
                return    # and raise open windows if in same process          

        # get text from the Tk widget
        text = self.getAllText()      # 2.1: a str string, with \n eolns,
        encpick = None                # even if read/inserted as bytes 

        # try Unicode encoding sources for saves in turn...

        # 1) try known encoding at latest Open or Save, if any - maybe
        if self.knownEncoding and (                                  # enc known?
           (forcefile     and self.savesUseKnownEncoding >= 1) or    # on Save?
           (not forcefile and self.savesUseKnownEncoding >= 2)):     # on SaveAs?
            try:
                text.encode(self.knownEncoding)
                encpick = self.knownEncoding
            except UnicodeError:
                pass

        # 2) try user input, prefill with known type, else next choice
        askuser = None  # [4.0]
        if not encpick and self.savesAskUser:
            self.update()  # else dialog doesn't appear in rare cases
            askuser = my_askstring(self, 'Save',
                                   'Enter Unicode encoding for save',
                                   initialvalue=(self.knownEncoding or 
                                                 self.savesEncoding or 
                                                 platformDefaultEncoding or ''))
            self.text.focus() # else must click
            if askuser:
                try:
                    text.encode(askuser)
                    encpick = askuser
                except (UnicodeError, LookupError):    # LookupError:  bad name 
                    pass                               # UnicodeError: can't encode
            # else continue to next options on Cancel (see onOpen)

        # 3) try config file; [4.0] don't try twice if was user input
        if not encpick and self.savesEncoding and self.savesEncoding != askuser:
            try:
                text.encode(self.savesEncoding)
                encpick = self.savesEncoding
            except (UnicodeError, LookupError):
                pass

        # 4) try platform default (utf-8 mode or not); [4.0] this wasn't right!
        if not encpick:
            try:
                text.encode(platformDefaultEncoding)
                encpick = platformDefaultEncoding
            except (UnicodeError, LookupError):
                pass

        # open in text mode for endlines + encoding (not bytes: still must encode!)
        if not encpick:
            my_showerror(self, 'Save', 'Could not encode for file:\n' + filename)  # [4.0]
        else:
            try:
                file = open(filename, 'w', encoding=encpick)
                file.write(text)
                file.close()
            except:
                my_showerror(self, 'Save', 'Could not write file:\n' + filename)  # [4.0]
            else:
                self.setFileName(filename)          # may be newly created
                self.text.edit_modified(0)          # 2.0: clear modified flag
                self.knownEncoding = encpick        # 2.1: keep enc for next save
                # but don't clear undo/redo stks!
                
                # [3.0] raise window above root, focus text
                # no longer needed if parent=self for dialogs
                """
                self.update()
                toplevel = self.findTopLevel()      # or self.liftWindows([self])
                toplevel.lift()                     # update(), else root on top
                self.text.focus_set()               # focus, else user must click
                """
             
                # [4.0] skip lockfile ops if filename has not changed 
                # else spurious delete+write whenever resave same file

                if priorfile != filename:

                    # [4.0] remove prior file's lockfile, but iff there is a prior file 
                    # and lockfile, and no other windows have file open in this process;

                    if self.is_last_open_in_process(priorfile):
                        self.delete_openfile_lockfile(priorfile)

                    # [4.0] write new lockfile now, after open is fully completed.
                    # Lockfile made for newly saved file, my pid, and current time.
                    # This happens in all cases, whether in-|cross-process dup or not.
                    # In-process dup will overwrite former lockfile for this pid,
                    # but we need just one per file for the cross-platform test.

                    try:                            
                        self.create_openfile_lockfile(filename)
                        trace(f'Wrote lockfile for {filename=}')
                    except:
                        print(f'Error writing lockfile for {filename=}')
                        print('Already-open tests will not be used for this file')

                return True   # iff actually saved a file (else returns default None)


    @modalMenuAction
    def onNew(self):
        """
        --------------------------------------------------------------------
        start editing a new file from scratch in current window;
        onClone and onPopup make new independent edit windows instead;
        --------------------------------------------------------------------
        """
        if self.text_edit_modified():    # 2.0
            if not my_askyesno(self, 'New', 'Text has changed: discard changes?'):
                return

        # [4.0] remove prior file's lockfile, but iff there is a prior file 
        # and lockfile, and no other windows have file open in this process

        if self.is_last_open_in_process():
            self.delete_openfile_lockfile()       # window's file, self pid

        self.setFileName(None)                    # clear text, reset state
        self.clearAllText()
        self.text.edit_reset()                    # 2.0: clear undo/redo stks
        self.text.edit_modified(0)                # 2.0: clear modified flag
        self.knownEncoding = None                 # 2.1: Unicode type unknown

        TextEditor.namelessid += 1                # [3.0] autosave filenames
        self.namelessid = TextEditor.namelessid   # my new id for text to be 


    @modalMenuAction
    def onQuit(self):
        """
        --------------------------------------------------------------------
        on Quit menu/toolbar select and wm border X button in toplevel windows;
        2.1: don't exit app if others changed;  2.0: don't ask if self unchanged;
        
        moved to the top-level window classes at the end since may vary per usage:
        a Quit in GUI might quit() to exit, destroy() just one Toplevel, Tk, or 
        edit frame, or not be provided at all when run as an attached component;
        check self for changes, and if might quit(), main windows should check
        other windows in the process-wide list to see if they have changed too; 
        --------------------------------------------------------------------
        """
        assert False, 'onQuit must be defined in window-specific sublass' 


    def text_edit_modified(self):
        """
        --------------------------------------------------------------------
        2.1: this now works! seems to have been a bool result type issue in tkinter;
        2.0: self.text.edit_modified() broken in Python 2.4: do manually for now; 
        --------------------------------------------------------------------
        """
        return self.text.edit_modified()
       #return self.tk.call((self.text._w, 'edit') + ('modified', None))




    ############################################################################
    # Edit menu commands
    ############################################################################


    @modalMenuAction
    def onUndo(self):
        """
        --------------------------------------------------------------------
        2.0: unlimited undos of edits, per Tk stacks
        --------------------------------------------------------------------
        """
        try:                                     # tk8.4 keeps undo/redo stacks
            self.text.edit_undo()                # exception if stacks empty
        except TclError:                         # menu tear-offs for quick undo
            my_showinfo(self, 'Undo', 'Nothing to undo')


    @modalMenuAction
    def onRedo(self):
        """
        --------------------------------------------------------------------
        2.0: unlimited redos of undone edits, per Tk stacks
        --------------------------------------------------------------------
        """
        try:
            self.text.edit_redo()
        except TclError:
            my_showinfo(self, 'Redo', 'Nothing to redo')


    @modalMenuAction
    def onCopy(self):
        """
        --------------------------------------------------------------------
        get text selected by mouse (etc.), and save it in the
        cross-app clipboard; this also happens on ctrl|command-C;
        --------------------------------------------------------------------
        """
        if not self.text.tag_ranges(SEL):
            my_showerror(self, 'Copy', 'No text selected')
        else:
            text = self.text.get(SEL_FIRST, SEL_LAST)
            self.clipboard_clear()
            self.clipboard_append(text)


    @modalMenuAction
    def onDelete(self, strict=True):
        """
        --------------------------------------------------------------------
        delete selected text, without saving it to clipboard;
        if not strict, okay if nothing is selected (for paste);
        --------------------------------------------------------------------
        """
        if not self.text.tag_ranges(SEL):
            if strict:
                my_showerror(self, 'Delete', 'No text selected')
        else:
            self.text.delete(SEL_FIRST, SEL_LAST)


    @modalMenuAction
    def onCut(self):
        """
        --------------------------------------------------------------------
        save to clipboard and delete seleted text (copy+delete);
        cut text is available both in PyEdit and other programs
        --------------------------------------------------------------------
        """
        if not self.text.tag_ranges(SEL):
            my_showerror(self, 'Cut', 'No text selected')
        else:
            allowModals()       # both of these are modal actions
            self.onCopy()       # reuse code: this is a combo action
            self.onDelete()


    @modalMenuAction
    def onPaste(self):
        """
        --------------------------------------------------------------------
        insert clipboard text at current insert cursor;
        this also generally happens on ctrl|command-V;
        
        [3.0] new paste model: delete selection so a paste
        replaces it instead of just inserting before/after,
        else user must manually delete just before paste;
        
        also do _not_ select pasted text: now that we're
        replacing selection, repastes would require a cick;
        they formerly did not, as selection was not deleted;
        prior select allowed immediate cut (rare use case);

        [3.0] need to manually insert Undo separators here,
        else consecutive Pastes, and an edit following them,
        are backed out as a unit;  see onDoChange() ahead;
        --------------------------------------------------------------------
        """
        try:
            text = self.selection_get(selection='CLIPBOARD')
        except TclError:
            my_showerror(self, 'Paste', 'Nothing to paste')
            return
        
        allowModals()
        self.text.config(autoseparators=0)      # [3.0] assume ctrl
        self.text.edit_separator()              # [3.0] delimit Undo change
        self.onDelete(strict=False)             # replace selected text, if any
        self.text.insert(INSERT, text)          # add at current insert cursor
        self.text.see(INSERT)
        self.text.edit_separator()              # [3.0] delimit Undo change
        self.text.config(autoseparators=1)      # [3.0] back to auto

        
        # was: select it, so can be cut
        #self.text.tag_remove(SEL, START, END)
        #self.text.tag_add(SEL, INSERT+'-%dc' % len(text), INSERT)


    def onSelectAll(self):
        """
        --------------------------------------------------------------------
        select entire text in widget, for copy/cut/etc.
        --------------------------------------------------------------------
        """
        self.text.tag_add(SEL, START, END+'-1c')   # select entire text
        self.text.mark_set(INSERT, START)          # move insert point to top
        self.text.see(INSERT)                      # scroll to top




    ############################################################################
    # Search menu commands
    ############################################################################


    @modalMenuAction
    def onGoto(self, forceline=None):
        """
        --------------------------------------------------------------------
        move text view, cursor, and selection to an input line number
        --------------------------------------------------------------------
        """
        line = forceline or my_askinteger(self, 'Goto', 'Enter line number')
        self.text.update()
        self.text.focus()
        if line is not None:
            maxindex = self.text.index(END+'-1c')
            maxline  = int(maxindex.split('.')[0])
            if line > 0 and line <= maxline:
                self.text.mark_set(INSERT, '%d.0' % line)      # goto line
                self.text.tag_remove(SEL, START, END)          # delete selects
                self.text.tag_add(SEL, INSERT, 'insert + 1l')  # select line
                self.text.see(INSERT)                          # scroll to line
            else:
                my_showerror(self, 'Goto', 'Bad line number')


    @modalMenuAction
    def onFind(self, lastkey=None, forcenocase=None):
        """
        --------------------------------------------------------------------
        search for a substring from current cursor, per Configs case setting;
        if found, move text view, cursor, and selection to found substring;
        [3.0] string-not-found is now an info message, not an error message;
        
        [3.0] for legacy reasons, this simple dialog still uses the textConfig
        case setting;  Change and Grep instead default to case-insensitive,
        and have new a 'Case?' toggle that allows case-sensitive to be used;
        Change reuses this method, however, so it has grown a forcenocase arg;

        [4.0] Searches for text containing emojis SEGFAULT on both macOS and 
        Linux when using Python 3.12 and Tk 8.6.13 on macOS and Tk 8.6.12 on 
        Linux.  No crashes were seen on Windows (Python 3.12 and Tk 8.6.13)
        and Android is moot because its Tk doesn't yet support emojis.

        The crash occurs in Tk's text.search, with final function call chain
        TextSearchAddNextLine => Tcl_UtfToLower => Tcl_UtfToUniChar.  This is
        a cross-platform bug with no possible fix at the Python-code level. 

        Workaround options, all of which could be limited to macOS + Linux:
        1) Force searches to always be case sensitive on and disable the 
           toggle button; this cuts very useful functionality
        2) Replace emojis with Unicode replacement characters presearch and
           restore them postsearch; this precludes searching for emojis :-(.
        3) Fetch text and implement a manual search routine in Python, 
           using Python's lowercase tools instead of Tk's; this is complex

        Went with #3 for full functionality - and it worked.  This requires
        fetching a string of all text past the insertion cursor which may be  
        wasteful, but this string is auto reclaimed each time this method exits
        and Python's GC is presumably smart enough to avoid leaking the memory.
        This workaround is used everywhere because it has no noticeable delay.
        Nit: this doesn't support Tk's regexp search, but PyEdit never has.

        See also macOS Save file-dialog crashes in my_asksaveasfilename.  Alas,
        tkinter seems to be growing buggy, especially on macOS, and joining the
        ranks of glitchy GUI libs (e.g., Kivy required dozens of workarounds 
        for the PPUS app, and its text widget works only for very small text).
        Can we get some quality control up in here?
        --------------------------------------------------------------------
        """
        trace = lambda *pargs, **kargs: None    # or print

        key = lastkey or my_askstring(self, 'Find', 'Enter search string')
        self.text.update()
        self.text.focus()
        self.lastfind = key
        if key:
            if forcenocase != None:
                nocase = forcenocase                        # [3.0] toggle in Change
            else:                                           # 2.0: nocase
                nocase = Configs.get('caseinsens', True)    # 2.0: config
                
            if False:                                       # search() -> "line.char" index
                # the crasher...
                trace('presearch', flush=True)
                where = self.text.search(key, INSERT, END, nocase=nocase)
                trace('postsearch', flush=True)
                pastkey = where + '+%dc' % len(key)         # index past key

            else:
                # [4.0] search the py (and manual) way
                tkoffset = self.text.index(INSERT)        # "line.char", lines 1+, chars 0+
                resttext = self.text.get(INSERT, END)     # from insert cursor through end
                findkey = key                             # self.getAllText() wastes space
                if nocase:
                    findkey  = key.casefold()             # what crashes in tk works in py (3.3+)
                    resttext = resttext.casefold()        # like lower() but neutralizes more
                pyoffset = resttext.find(findkey)
                if pyoffset == -1:
                    where = ''
                else:
                    where   = INSERT + f'+{pyoffset}c'
                    pastkey = INSERT + f'+{pyoffset + len(key)}c'    # add chars once [4.0]
                    trace(f'{tkoffset=} {pyoffset=} {where=} {pastkey=}')

            if not where:                                            # don't wrap to top
                my_showinfo(self, 'Find', 'String not found')
            else:
                self.text.tag_remove(SEL, START, END)       # remove any sel
                self.text.tag_add(SEL, where, pastkey)      # select key
                self.text.see(where)                        # scroll display, pre INSERT mod! [4.0
                self.text.mark_set(INSERT, pastkey)         # for next find


    def onRefind(self):
        """
        --------------------------------------------------------------------
        find again from last find (or start find if first time);
        no need for @modalMenuAction, as onFind already ensures,
        and would need to allowModals() to clear lock if used;
        --------------------------------------------------------------------
        """
        self.onFind(self.lastfind)


    def onChange(self):
        """
        --------------------------------------------------------------------
        non-modal find/change dialog - can use to both find, and find+replace;
        2.1: pass per-call/dialog inputs to callbacks, may be > 1 change dialog open;
        TBD: should this have a "Change All" option?  inclined to say no: dangerous!

        [3.0] binding Enter=Find doesn't work here: it would delete the selected text
              because the Text widget gets focus after each Find to speed new edits;
        [3.0] binding Escape=show/hide help fails too: can't pack in  gridded parent
        [3.0] default to case-insensitive, and add 'Case?' toggle for sensitive;
        [3.0] on Mac, set default app menubar for nonmodal dialogs, else erased;
        [3.0] add 'Top' button to goto top and re-search: for manual wrap-arounds;
        [3.0] fix undo separators so each change undone as a unit: see onDoChange();
        [3.0] lift() dialog so not hidden, focus() find text to save initial click;

        [4.0] total rewrite of GUI build/layout to move Top left of Help, so same 
        as others (Run, Grep, Font), and so help text isn't too narrow on Android 
        (else scrunched between buttons); required dropping grid() for pack() 
        throughout to conform to addDialogHelp assumptions; l-a-f largely same;

        [4.0] there is some nonsense here to subvert macOS's inactive shading
        of text in the edit window and unfocused shading of widgets in the 
        dialog; bg/fg configs for selection may help for text but seem tmi;   
        --------------------------------------------------------------------
        """
        popup = Toplevel(self)                # pertains to and closed with self
        popup.withdraw()                      # [4.0] hide to avoid flash on Windows
        try_set_window_icon(popup)            # [3.0] icons (and leave resizable)
        popup.title('PyEdit - Find/Change')

        # [3.0] call guimaker's Mac menubar fixer for nonmodal dialog window
        fixAppleMenuBarChild(popup)

        # [4.0] on Android, limit size but don't expand
        self.limitWindowToScreen(popup)

        # [4.0] don't allow vertical resizes: Help won't reappear
        popup.resizable(width=True, height=False)

        #-------------------------------------------------------------------
        # local callback handlers use names in enclosing method's scope;
        # Find+Change+Top lift() dialog so it isn't covered by text window;
        #
        # [4.0] all three now also focus() dialog on macOS so widgets not 
        # shaded there to look inactive after a Find/Change/Top - this dialog
        # is used to repetitively find/change throughout the file; but macOS 
        # ONLY: focus() stops found text from being highlighted on Windows!
        #
        # [4.0] after the prior note's focus() on macOS, the inactive select 
        # bg COLOR of the text widget is also nearly imperceptible for darker
        # backgrounds; changing it to the active select bg while the find 
        # dialog is open highlights found text better sans focus (usually);
        # this doesn't matter on Windows, Linux, or Android, which don't use
        # inactive colors, and seem to highlight more obviously (why macOS?);
        #
        # save/restore original only on FIRST Find open: see onFindClose;
        # now also UNDERLINES selection and makes its font BOLD during Find,
        # else it can be lost;  skipped selection mods - border, larger font:
        # self.text.tag_config(SEL, relief=SOLID, borderwidth='2px')
        # self.text.tag_config(SEL, font=[currtextfont[0], int(self.currtextfont)+2, 'bold'])
        #
        # later made a user CONFIG that's preset to True for macOS only, but can
        # be enabled for other hosts for accessibility; color is still macOS only;
        # nit: underlining won't help if it's enabled in the text font, but rare;
        #-------------------------------------------------------------------

        if Configs.get('emphasizeFindChangeMatches', False):
            self.numFindDialogsOpen += 1
            if self.numFindDialogsOpen == 1:    # first Find/Change opened?

                # all platforms
                currtextfont = self.currentFont()
                self.text.tag_config(SEL,
                    font=(currtextfont[:2] + ['bold']), underline=True)
                self.origFindFont = currtextfont

                if RunningOnMacOS:
                    activeSelectBg   = self.text.cget('selectbackground')
                    inactiveSelectBg = self.text.cget('inactiveselectbackground')
                    self.text.config(
                        inactiveselectbackground=activeSelectBg)
                    self.origFindInactiveSelectBg = inactiveSelectBg

        def onFindClose():
            """
            [4.0] on Find/Change dialog "X" close, restore the original 
            font and inactive-select bg color, backing out the hack above.
            This won't help if dialog remains open, but impact is minimal.
            Uses counter to only do this when LAST Find dialog opened is 
            closed, else random closes may restore too soon - or wrongly.
            note: a font without underlining does not remove underlining!
            """
            if Configs.get('emphasizeFindChangeMatches', False):
                self.numFindDialogsOpen -= 1
                if self.numFindDialogsOpen == 0:    # last Find/Change closed?
                    # all platforms
                    self.text.tag_config(SEL,
                        font=self.origFindFont, underline=False)   # not implied by font

                    if RunningOnMacOS:
                        self.text.config(
                            inactiveselectbackground=self.origFindInactiveSelectBg)
            popup.destroy()

        def onFind():
            """
            find next occurrence of search string
            this is like Find but with a case toggle
            """
            nocase  = not caseSensVar.get()      # [3.0] pass toggle's inverse too
            findstr = entries[0].get()           # [3.0] don't trigger Find popup
            if not findstr:
                # [4.0] lift popup not self=editwin (redundant?)
                my_showerror(popup, 'Find/Change', 'Please enter a Find string')
            else:
                self.onFind(findstr, nocase)     # runs normal find dialog callback
            popup.lift()                         # [3.0] raise above text window
            if RunningOnMacOS:
                popup.focus()                    # [4.0] reactivate find dialog window

        def onChange():
            """
            replace last found text and refind next
            propagate the case toggle for the refind
            """
            nocase   = not caseSensVar.get()     # [3.0] pass toggle's inverse too
            findstr  = entries[0].get()          # [3.0] don't trigger Find popup
            changeto = entries[1].get()
            if not findstr:
                # [4.0] lift popup not self=editwin
                my_showerror(popup, 'Find/Change', 'Please enter a Find string')
            else:
                self.onDoChange(findstr, changeto, nocase)
            popup.lift()
            if RunningOnMacOS:
                popup.focus()                    # [4.0] reactivate find dialog window

        def onTop():
            """
            convenience: go to top of this file to search again
            deselect all so a Change has nothing to silently erase
            """
            self.onGoto(1)
            self.text.tag_remove(SEL, START, END)          # remove selection
            popup.lift()
            if RunningOnMacOS:
                popup.focus()                    # [4.0] reactivate find dialog window

        #
        # back to onChange
        #
 
        # [4.0] restore inactive select bg on "X"
        popup.protocol('WM_DELETE_WINDOW', onFindClose)

        entries = []
        actrows = [('Find text?', 'Find', onFind), ('Change to?', 'Change', onChange)]
        for (actlab, actbut, action) in actrows:
            rowfrm = Frame(popup)
            rowfrm.pack(side=TOP, fill=X)

            lab = Label(rowfrm, text=actlab, relief=RIDGE, width=15)
            lab.pack(side=LEFT, expand=NO)

            btn = Button(rowfrm, text=actbut, width=6, command=action)
            btn.pack(side=RIGHT, expand=NO, fill=X)

            ent = Entry(rowfrm, width=30)
            ent.pack(side=LEFT, expand=YES, fill=X)
            entries.append(ent)

        rowfrm = Frame(popup)
        rowfrm.pack(side=TOP, fill=X)
                
        # [3.0] add case-sensitivity toggle, on right of last row
        caseSensVar = IntVar()
        chk = Checkbutton(rowfrm, text='Case?')
        chk.config(variable=caseSensVar)
        caseSensVar.set(0)
        chk.pack(side=RIGHT, anchor=E)

        # [3.0] add Top button for manual wrap-around and re-search
        # [4.0] move Top to left of Help, else help text gets no space on Android
        ctrfrm = Frame(rowfrm)
        ctrfrm.pack(anchor='c')
        Button(ctrfrm, text='Top', command=onTop).pack(side=LEFT)
        
        # [3.0] add usage help hints pulldown (dialog-specific: not a popup)
        # [4.0] reformat from fixed layout to word-wrap to dialog window size 

        helptext = (
        'This stay-up dialog allows you to repeatedly find and/or change text in '
        'the PyEdit window from which the dialog was opened.  It uses two main '
        'buttons with associated input strings at the top of the dialog:'
        '\n\n'
        '%(dialogHelpBullet)s Find (search string):'
        '\n\n'
        'Searches ahead for the next appearance of the first string, '
        'and highlights and selects it but does not replace it.'
        '\n\n'
        '%(dialogHelpBullet)s Change (replacement string):' 
        '\n\n'
        'Replaces the last-found and highlighted string with the second '
        'string, and searches ahead for the next occurrence of the first string.'
        '\n\n'
        'Repeated Finds refind and select the search string but do not replace it.  '
        'Repeated Changes replace and refind the found search string on each press.'
        '\n\n'
        'Searches run from current cursor location to end of file; click any '
        'text to set the cursor, or tap Top to jump to top of file to search anew.  '
        'For global search and replace, do Top, then Find, then Change repeatedy.' 
        '\n\n'
        "In this dialog, finds are case-insensitive ('a' ==' A') by default; "
        "turn the 'Case?' toggle on to match case exactly ('a' != 'A').  "
        'Searches always look for a literal string, not a pattern.'
        '\n\n'
        'The Enter (return) key does not perform any action in this dialog, '
        'because its intent is ambiguous; click Find or Change per your goals.  '
        "Refind (e.g., control/Alt+g) also repeats this dialog's prior Find."
        '\n\n'
        "Press 'Help' to open and close this help.  Tips: see the Search "
        "menu's Grep command for searching external files instead of PyEdit "
        'windows, and its Find+Refind actions (and their accelerator keys) '
        'for a simpler but limited alternative to the Find button here.'
        ) % globals()

        # [4.0] focus post deiconify else no-op on Windows
        # focus was used here in 3.0, but not in other dlgs
        # withdraw+deiconiy was used in only PickFont in 3.0

        self.addDialogHelp(popup, ctrfrm, helptext)    # see grep, Escape=Help?
        popup.deiconify()                              # [4.0] unhide flash-free
        entries[0].focus_set()                         # [3.0] save user a click [4.0] mod
                                                       

    def onDoChange(self, findstr, changeto, casetoggle):
        """
        --------------------------------------------------------------------
        on Change in nonmodal find/change dialog: change and refind;
        
        [3.0] two undo/redo changes;  FIRST, force a new separator
        on the Tk undo stack, so that an Undo undoes just this change;
        not normally required in autoseparator mode, but in some Tks,
        an Undo undoes *all* find/change edits at once (a Tk bug?);

        SECOND, disable autoseparators temporarily here so that an
        Undo backs out the entire change as a whole;  even when auto
        separators work, users must Undo both a delete and an insert;

        note that redundant separators are simply discarded, per Tk's
        docs: see http://www.tcl.tk/man/tcl8.4/TkCmd/text.htm#M73;
        autoseparators are also odd for PyEdit's Paste and required a
        similar fix above, else Undo backs out Paste + following edits;
        --------------------------------------------------------------------
        """
        if self.text.tag_ranges(SEL):                      # must find first
            self.text.config(autoseparators=0)             # [3.0] assume ctrl
            self.text.edit_separator()                     # [3.0] per above
            self.text.delete(SEL_FIRST, SEL_LAST)          
            self.text.insert(INSERT, changeto)             # deletes if empty
            self.text.see(INSERT)
            self.onFind(findstr, casetoggle)               # goto next match
            self.text.update()                             # force refresh
            self.text.edit_separator()                     # [3.0] per above           
            self.text.config(autoseparators=1)             # [3.0] back to auto


    def onGrep(self):
        """
        --------------------------------------------------------------------
        new in version 2.1: threaded external-file search;
        search matched filenames in entire directory tree for string;
        matches listbox clicks open matched file at line of occurrence;
        spans 4 windows: grep => grepping => matches list => match edit;

        search is either threaded or spawned in a process so the GUI
        remains active and is not blocked, and to allow multiple greps
        to overlap in time;  could use PP4E threadtools for threads,
        but avoid polling loop if no active grep;

        grep Unicode policy: text files content in the searched tree 
        might be in any Unicode encoding: we don't ask about each (as
        we do for opens), but allow the encoding used for the entire
        tree to be input, preset it to the platform filesystem or 
        text default, and skip files that fail to decode; in worst 
        cases, users may need to run grep N times if N encodings might
        exist;  else opens may raise exceptions, and opening in binary
        mode might fail to match encoded text against search string;

        TBD: better to issue an error if any file fails to decode? 
        but utf-16 2-bytes/char format created in Notepad may decode 
        without error per utf-8, and search strings won't be found;
        TBD: could allow input of multiple encoding names, split on 
        comma, try each one for every file, without open loadEncode?
        
        [3.0] note: latin-1 may find more than utf-8 in some cases;
        [3.0] added stats with #Unicode errors to results window;
        [3.0] code workarounds to a Python 3.5/Tk 8.6 thread crash;
        [3.0] default to case-insensitive, and add 'Case?' toggle;
        [3.0] on Mac, set default app menubar for nonmodal dialogs;
        [4.0] default encoding from locale, not sys.getdefaultencoding();
        --------------------------------------------------------------------
        """
        from PP4E.Gui.ShellGui.formrows import makeFormRow
        platformDefaultEncoding = locale.getpreferredencoding(False)    # [4.0]

        # nonmodal dialog: get dirnname, filenamepatt, grepkey
        popup = Toplevel()                   # stays open: not closed with self
        popup.withdraw()                     # [4.0] hide to avoid flash on Windows
        try_set_window_icon(popup)           # [3.0] icons (and leave resizable)
        popup.title('PyEdit - Grep')

        # [3.0] call guimaker's Mac menubar fixer for nonmodal dialog window
        fixAppleMenuBarChild(popup)

        # [4.0] On Android, limit size but don't expand
        self.limitWindowToScreen(popup)

        # [4.0] don't allow vertical resizes: Help won't reappear
        popup.resizable(width=True, height=False)

        # [4.0] caveat: Browse opens a native file dialog everywhere unlike 
        # my_askopenfilename used for Open, but wedging this into makeFormRow
        # is complex: need self=editwindow for state, but won't focus dialog

        # [3.0] implement and use folder browse button for directory root
        # [4.0] focus=True saves the user a (likely) click 

        var1 = makeFormRow(popup,
                   label='Directory root', width=18, browse=True, 
                   folder=True, app='PyEdit - Grep')
        var2 = makeFormRow(popup,
                   label='Filename pattern', width=18, browse=False)
        var3, ent3 = makeFormRow(popup, 
                   label='Search string', width=18, browse=False, 
                   focus=True)
        var4 = makeFormRow(popup, 
                   label='Content encoding', width=18, browse=False)

        # prefill initial/suggested values
        #var1.set('.')                       # current dir not very useful: pyedit's
        thisfile = self.getFileName()        # use dir of window's abs filename, if any
        if thisfile != None:
            var1.set(os.path.dirname(thisfile))
        else:
            var1.set('.')                    # or '*.py*' for .pyw (.pyc error out)
        var2.set('*.py')                     # all py files in tree (but not .pyw)
        var4.set(self.opensEncoding or       # [4.0] try utf-8 config first, like Open
                 platformDefaultEncoding)    # for file content, not filenames

        # [4.0] use Change's cofing to let Help span window
        rowfrm = Frame(popup)
        rowfrm.pack(side=TOP, fill=X)

        # [3.0] add case-sesitivity toggle, off by default
        case = IntVar()
        chkb = Checkbutton(rowfrm, text='Case?')
        chkb.config(variable=case)
        case.set(0)
        chkb.pack(side=RIGHT, anchor=N)

        def onGrepSearch():
            # vars in per-call/dialog enclosing scope, not per-editor self
            if not var3.get():
                # [4.0] don't allow empty search key, lift popup after
                my_showerror(popup, 'Grep', 'Please enter a search string')
            else:
                self.onDoGrep(
                    var1.get(), var2.get(), var3.get(), var4.get(), case.get())

        ctrfrm = Frame(rowfrm)
        ctrfrm.pack(anchor='c')
        sbtn = Button(ctrfrm, text='Search', command=onGrepSearch)
        sbtn.pack(side=LEFT)
        popup.bind('<Return>', lambda event: onGrepSearch())   # [3.0] Enter=Search

        # [3.0] add usage help hints pulldown (dialog-specific: not a popup)
        # [4.0] reformat from fixed layout to word-wrap to dialog window size 

        helptext = (
        'This stay-up dialog performs external-file search.  On each Search click '
        'it searches all the files in the entire folder tree at "Directory root", '
        'whose names match "Filename pattern", for the provided "Search string".'
        '\n\n'
        'Searches are run in parallel processes (or threads, if configured) that '
        'report their results in new popups on completion.  Searches do not block '
        "PyEdit's GUI, and multiple searches may be run at the same time.  "
        'Dialog inputs:'
        '\n\n'
        '%(dialogHelpBullet)s Directory root:'
        '\n\n'
        'The pathname of the folder tree whose files you wish to search.  '
        "Use Browse to pick a directory with your platform's file-dialog GUI, "
        'or type or paste a directory pathname into the input field manually.  '
        "This is prefilled with the directory of the window's file, if known.  "
        "In pathname matches, '/' is the same as '\\' on Windows, and case "
        "differences are ignored everywhere ('a' == 'A')."
        '\n\n'
        '%(dialogHelpBullet)s Filename pattern:'
        '\n\n'
        'The basename pattern of the files you wish to search in the folder tree ' 
        '(e.g., "*.html" searches all HTML files in or below Directory root).  In this, '
        '*=any substring, ?=any character, [seq]/[!seq]=any character in/not in seq, '
        'and any other characters match literally.  Match special characters literally '
        'by enclosing in brackets (e.g., x[?]y).  Basename matches are always case '
        "insensitive ('a' == 'A').  Tip: use \"*.py*\" to include both .py and .pyw "
        'Python source-code files; non-text files matching the pattern (e.g., .pyc) '
        'are skipped on errors.'
        '\n\n'
        '%(dialogHelpBullet)s Search string:'
        '\n\n'
        'The string you wish to search for in all matching files in the folder tree.  '
        'A literal string (not pattern), matched case-insensitively by default '
        "('a' == 'A').  Set the 'Case?' toggle on to match case exactly ('a' != 'A')."
        '\n\n'
        '%(dialogHelpBullet)s Content encoding:'
        '\n\n'
        'The name of the Unicode text encoding to apply when reading all files, '
        "prefilled with your platform's default (subject to opensEncoding in "
        'textConfig.py).  UTF-8 is common and handles ASCII too, but some files '
        'originating on Windows or the internet may require others (e.g., cp1252, '
        'latin-1, or utf-16); rerun with other encodings if Unicode errors != 0 in '
        'the results popup and the files skipped on these errors are valid text '
        '(not binary data).'
        '\n\n'
        'Double-Click lines in the post-search popup to go to matching files/lines: '
        'each opens in a new PyEdit window that scrolls to and highlights a match.  '
        'This popup displays matches as "filepath@linenumber [matchinglinetext]".'
        '\n\n'
        'When there are very many matches, a dialog is issued allowing you to skip '
        'the matches-list display, because it may stall the GUI, and even hang it in '
        'worst cases.  Skipping is recommended for pathologically-large results.'
        '\n\n'
        'Tips: this dialog\'s Enter key also starts a search, and Escape opens or '
        'closes this help display.  Run PyEdit in a console (command line) to see '
        'which files fail to decode; a latin-1 encoding may be helpful on errors.'
        '\n\n'
        'Grep is useful for tracking down all occurrences of a string among a set of '
        'text files on your computer.  To search just the text in one PyEdit window '
        'instead, see the Search menu\'s Find and Change commands.  Note: Grep may '
        "not work if PyEdit is run in Python's IDLE GUI; start PyEdit in other ways."
        ) % globals()

        self.addDialogHelp(popup, ctrfrm, helptext)    # see grep, Escape=Help
        popup.deiconify()                              # [4.0] unhide flash-free
        ent3.focus_set()                               # [4.0] post deiconify on Windows


    def addDialogHelp(self, popup, btnfrm, helptext):
        """
        --------------------------------------------------------------------
        [3.0] add a Help button on the LEFT of btnfrm that opens/closes an
        embedded text widget with hints, and bind Escape on popup window to
        open it too;  factored to a common method here so reusable for
        other dialogs (currently: pickfont, find/change);  caller: make
        popup window height (only) nonresizable, else help may be munged;

        [4.0] All custom dialogs' Help text (in Change, Grep, Pick Font,
        and Run Code) was rewritten to word-wrap to the curent size of 
        the dialog window, instead of using a fixed format wit hindentation
        that caused the window to expand but was unreadable when the window
        failed to expand after user resizes.  Code here was changed to wrap
        a single string and skip line tests and joins.  This also avoids 
        help-text scrunches on narrow Android devices.
        --------------------------------------------------------------------
        """
        from tkinter.scrolledtext import ScrolledText
        
        helpopen = False
        def onDialogHelp():
            # vars in per-call/dialog enclosing scope, not per-editor self
            nonlocal helpopen
            if not helpopen:
                helpfrm.pack(side=BOTTOM, fill=X, padx=0, pady=0)    # [4.0] drop padding
            else:
                helpfrm.pack_forget()
            helpopen = not helpopen    # toggle on/off on each call

        hbtn = Button(btnfrm, text='Help', command=onDialogHelp)
        hbtn.pack(side=LEFT)
        popup.bind('<Escape>', lambda event: onDialogHelp())         # [3.0] Escape=Help
        helpfrm = Frame(popup, border=2, relief=RIDGE)

        """CUT
        display = ScrolledText(helpfrm,
                       height=min(20, len(helptext)),
                       width=max(len(line) for line in helptext) + 1)
        display.insert(END, '\n'.join(helptext))
        CUT"""

        display = ScrolledText(helpfrm,
                       wrap='word')               # [4.0] wrap better if window small
        display.insert(END, helptext)             # [4.0] assume a single string
        display.config(state=DISABLED)            # read-only (and copy on Windows only)
        display.pack(fill=X)                      # caller makes dialog resizable or not 


    def onDoGrep(self, dirname, filenamepatt, grepkey, encoding, case):
        """
        --------------------------------------------------------------------
        on Go in grep dialog: populate scrolled list with matches, by
        spawning a non-GUI thread/process that produces matches, and
        a GUI timer loop that polls for and consumes the match result;
        
        note that multiple greps can OVERLAP in time, because each grep
        active has its own result queue, producer task, and consumer loop,
        and each grep displays its results in its own popup list window
        (but your drive may run slowly if many greps are reading at once);
        
        tbd: should the producer thread be daemonic so it dies with app?
        [3.0] give more details in the popup window than just grepkey;
        [3.0] this is now coded to spawn grep in one of a variety of 
        ways to possibly work around a Python 3.5/Tk 8.6 threading crash;
        [4.0] multiprocessing fails on Android: config sets thread option;
        --------------------------------------------------------------------
        """
        import threading, queue, _thread, multiprocessing   # latter patched 

        # make non-modal un-closeable dialog
        mypopup = Toplevel()                   # [3.0] not Tk, not closed with self
        try_set_window_icon(mypopup)           # [3.0] cusom icon where supported
        mypopup.title('PyEdit - Grepping')
        mypopup.protocol('WM_DELETE_WINDOW', lambda: None)  # ignore X close

        # [3.0] call guimaker's Mac menubar fixer for nonmodal dialog window
        fixAppleMenuBarChild(mypopup)

        # [4.0] on Android, limit size but don't expand
        self.limitWindowToScreen(mypopup)

        # [3.0] more details in the busy popup
        statusfrm = Frame(mypopup)
        statusfrm.pack(padx=20, pady=20)
        status1 = 'Grep is searching for %r using %r' % (grepkey, encoding)
        status2 = 'in all files %r in tree %r' % (filenamepatt, dirname)
        Label(statusfrm, text=status1).pack()
        Label(statusfrm, text=status2).pack()

        # start the non-GUI producer thread or process [3.0]
        spawnMode = Configs.get('grepSpawnMode') or 'multiprocessing'
        print('Using', spawnMode)
        grepargs = (filenamepatt, dirname, grepkey, encoding, case)

        if spawnMode == '_thread':
            # basic thread module (used with no crashes in pymailgui, [4.0] preset on Android)
            myqueue = queue.Queue()
            grepargs += (myqueue,)
            _thread.start_new_thread(grepThreadProducer, grepargs)
            
        elif spawnMode == 'threading':
            # enhanced thread module (original coding: crashes? - see target func's docs)
            myqueue = queue.Queue()
            grepargs += (myqueue,)
            threading.Thread(target=grepThreadProducer, args=grepargs).start()

        elif spawnMode == 'multiprocessing':
            # thread-like processes module (slower startup, faster overall?, [4.0] fails on Android)
            myqueue = multiprocessing.Queue()
            grepargs += (myqueue,)
            multiprocessing.Process(target=grepThreadProducer, args=grepargs).start()

        else:
            assert False, 'bad grepSpawnMode setting'

        # start the GUI consumer polling loop
        self.grepThreadConsumer(
            grepkey, filenamepatt, case, encoding, myqueue, mypopup)


    def defunct_grepThreadProducer(self,
                filenamepatt, dirname, encoding, grepkey, case, myqueue):
        """
        in a non-GUI parallel thread: queue find.find results list;
        [3.0] due to a thread crash in Python 3.5/Tk 8.6, this code
        was rewritten to use multiprocessing, and consequently moved
        to a top-level, picklable function above in this file; see that
        function for documentation removed here; a top-level class with
        a run() method works too, but needs extra code to save args; 
        """
        pass  # UNUSED: now a top-level function near the top of this file      


    def grepThreadConsumer(self, grepkey, patt, case, encoding, myqueue, mypopup):
        """
        --------------------------------------------------------------------
        in the main GUI thread: poll in a timer loop to watch
        the queue for a results list, and pass it on to handler;
        there may be multiple active grep threads/loops/queues;
        there may be other types of threads/checkers in process,
        especially when PyEdit is attached component (PyMailGUI);

        [3.0] Tk's widget.after() method requires that widget not be
        destroyed before the timer expires, else no callback occurs;
        since "self" is the standalone or embedded edit window from
        which the grep dialog was opened and may be closed while the
        grep searches, use the implicit or explicit first-created Tk(),
        tkinter._default_root, that endures for the program, but use
        "self" fallback if it's None (autoSaveLoop() for more details);
        --------------------------------------------------------------------
        """
        import queue, tkinter
        try:
            matches = myqueue.get(block=False)
        except queue.Empty:
            myargs  = (grepkey, patt, case, encoding, myqueue, mypopup)
            topwin  = getattr(tkinter, '_default_root', None) 
            regwin  = topwin or self
            regwin.after(250, self.grepThreadConsumer, *myargs)   # 4 per sec
        else:
            mypopup.destroy()     # close status window
            self.update()         # ensure it's erased now

            # notify with simple popup (Mac: slide-down in text window)
            # then show results, but no popup if no results (1=stats)
            # update: show popup anyhow, for error stats (e.g., Unicode)
            # update: but self may be destroyed/closed before the grep
            # finishes, or while grep dialog remains on screen: punt!
            
            if False:   # <= Nope
                my_showinfo(self, 'Grep', 'Grep found %d matches for: %r' %
                        (len(matches) - 1, grepkey))

            # [3.0] warn the user about a huge number of matches; the 
            # results list load is not threaded, and can easily hang 
            # the GUI, if not kill it outright due to memory issues;
            
            # [4.0] this message is too wide on Android, but uneasy to 
            # fix - tkinter runs a Tk Message and provides no way to run 
            # a maxsize() call).  Partial fix: added \n\n after #matches.
            # A full fix needs a custom dialog: punt sans Android usage.

            if True or len(matches) > 1:    # <= do always: no initial popup
                proceed = True
                if len(matches) > 2500:
                    proceed = my_askyesno(None, 'Grep: Many Matches Warning',
                              'There are %s matches.\n\nA large number of '
                              'matches may take some time to display, and a very '
                              'large number may hang the GUI altogether.\n\n'
                              'Continue to the match results list?' % 
                              format(len(matches) - 1, ','))
                    self.update()
                if proceed:
                    print('Matches list open', flush=True)
                    self.grepMatchesList(matches, grepkey, patt, case, encoding)


    def grepMatchesList(self, matches, grepkey, patt, case, encoding):
        """
        --------------------------------------------------------------------
        populate list after successful matches, open files on clicks;
        we already know file Unicode encoding from the search: use 
        it here when filename clicked, so the open doesn't ask user;

        [3.0] give number matches and file failures in a label too;
        these are now passed as matches[0] from the producer thread,
        else they show up only in the console (when there is one);
        
        [3.0] need to replace any non-BMP Unicode characters in lines
        for display in Tks ~8.6 (though 8.7 may support emojis); also
        truncate any weirdly-long lines to ensure they don't trigger
        a known Tk crash (see the producer code above for details:
        it's unlikely that the code in this consumer is a factor, as
        the crash occurs _before_ the producer queues its results);

        [3.0] avoid a bad line# error message if file was already open
        and user declined to reopen it, by checking return value of an
        explicit onOpen() call after constructor run;  also close the
        new edit window in this event: we could scroll to the line in
        the existing and lifted window, but the user may not want this,
        there may be > 1, and onOpen()'s result is just boolean (tbd);

        [3.0] tries to avoid a brief empty-window "flash" that appears
        _only_ for the PyInstaller frozen executable of PyMailGUI on 
        Windows (not for PyEdit's own exe, or source or Mac app), but 
        the withdraw/deiconify doesn't seem to help, even if update()
        immediately after, for reasons tbd; punt -- likely a Tk issue;
        --------------------------------------------------------------------
        """
        from PP4E.Gui.Tour.scrolledlist import ScrolledList         # move to top?

        # [3.0] grab stats from first item in matches list
        summary, matches = matches[0], matches[1:]                  # or x, *y
        searchstats = tuple(int(num) for num in summary.split())
        assert searchstats[0] == len(matches)

        # catch list double-click: parse match line, open editor
        class ScrolledFilenames(ScrolledList):
            def runCommand(self, selection):  
                file, line = selection.split('  [', 1)[0].split('@')
                editor = TextEditorMainPopup(
                     winTitle='Grep match popup'          # parent=None=Tk root
                     )                                    # not closed with self
                opened = editor.onOpen(file, encoding)
                if opened:
                    editor.onGoto(int(line))        # goto line in new window
                    editor.text.focus_force()       # no, really  
                else:
                    editor.onQuit()   # close new edit window: it's bogus now

        # new non-modal window
        popup = Toplevel()           # [3.0] not Tk(), not closed with self
        popup.withdraw()             # [3.0] avoid flash (Win PyMailGUI exes only)
        try_set_window_icon(popup)   # [3.0] custom icon where supported
        popup.title('PyEdit - Grep matches: %r (%s)' % (grepkey, encoding))

        # [3.0] make window larger initially (esp. on Mac)
        if not RunningOnAndroid:
            screenwide = popup.winfo_screenwidth()    # full screen size, in pixels
            screenhigh = popup.winfo_screenheight()
            popup.geometry('%dx%d' % (screenwide * 0.75, screenhigh * 0.50))
            #popup.geometry('%dx%d' % (min(screenwide, 900), min(screenhigh, 300)))

        # [3.0] call guimaker's Mac menubar fixer for nonmodal dialog window
        fixAppleMenuBarChild(popup)

        # [4.0] on Android, limit size but don't expand => geometry above makes moot?
        self.limitWindowToScreen(popup)

        # [3.0] show search-stats label 
        infotemplate = ('Stats: key=%r, patt=%s, case=%d, encoding=%s, '
            'matches=%d, files=%d, errors=(Unicode=%d, IO=%d, other=%d, find=%d)')
        infotext = infotemplate % ((grepkey, patt, case, encoding) + searchstats)
        Label(popup, text=infotext, bg='black', fg='white').pack(fill=X)

        # [3.0] sanitize Unicode, truncate pathologically-long lines
        # [3.0] add horizontal scroll and configurable list font
        matches = [fixTkBMP(match) for match in matches]
        matches = [match[:500]     for match in matches]
        ScrolledFilenames(parent=popup,
                          options=matches,
                          horizscroll=True,
                          listfont=Configs.get('grepMatchesFont', None))

        popup.deiconify()   # show window now
        popup.lift()        # raise on screen now (former notify popup dropped)




    ############################################################################
    # View menu commands [3.0]
    ############################################################################


    def currentFont(self):
        """
        --------------------------------------------------------------------
        return Python font spec (family, size, style) of current text font;
        much magic here - need to parse out tcl parts and strip '{}' if present:
        
        'courier 12 bold' => ['courier', '12', 'bold']
        'courier 12 {bold italic}' => ['courier', '12', 'bold italic']
        'courier 12 {}' => ['courier', '12', '']
        '{courier new} 12 {bold italic}' => ['courier new', '12', 'bold italic']

        result tuple contains all strings: convert size to int as needed;
        result also padded with default values to make length=3 always
        (if config-file fonts omit the size and/or style parts they work,
        but fonstr here gets just 1 or 2 parts; onPickFont() sets all 3);
        --------------------------------------------------------------------
        """
        import re
        fontstr = self.text.config()['font'][-1]                    # at end of config val 
        tclsubs = re.findall(r'(?:\{[^\}]*\})|(?:[^ ]+)', fontstr)  # '{non-}}' or 'nonblank'
        pyparts = [sub.strip('{}') for sub in tclsubs]              # drop '{}' if present

        # pad with default size/styles if missing (family is required)
        if len(pyparts) == 1:
            pyparts.append(0)      # omitted size: 0=default for family
        if len(pyparts) == 2:
            pyparts.append('')     # omitted styles: ''=default=normal+roman
            
        return pyparts  # (family, size, style), all strings


    def fontResize(self, incr=None, actual=None):
        """
        --------------------------------------------------------------------
        increment or set the current font size and reconfigure
        [4.0] don't change the window size along with the text font size;
        the only thing that worked for this was geometry(), others shown;

        nit: font picks and cycling don't retain window size like this does,
        unless window size already been changed by user or font zoom here...
        update: onFontList and onPickFont now both retain window size too;
        --------------------------------------------------------------------
        """

        before = self.master.geometry()
        ##self.pack_propagate(False)
        ##self.master.resizable(width=False, height=False)

        try:
            family, size, style = self.currentFont()
            resize = int(size) + incr if incr else actual
            self.text.config(font=(family, resize, style))
        except:
            my_showerror(self, 'Font', 'Cannot resize current font')

        self.master.geometry(before)
        ##self.pack_propagate(True)
        ##self.master.resizable(width=True, height=True)


    def onFontPlus(self):
        """
        --------------------------------------------------------------------
        Zoom In: increment the current font size and reconfigure
        --------------------------------------------------------------------
        """
        self.fontResize(+1)


    def onFontMinus(self):
        """
        --------------------------------------------------------------------
        Zoom Out: decrement the current font size and reconfigure
        --------------------------------------------------------------------
        """
        self.fontResize(-1)

        
    def onFontList(self):
        """
        --------------------------------------------------------------------
        pick next font spec in configurable list (font cycling)
        [4.0] keep window size the same, just like fontResize zooms;
        --------------------------------------------------------------------
        """
        before = self.master.geometry()
        self.text.config(font=self.fonts[0])       # resizes the text area as needed
        self.fonts.append(self.fonts.pop(0))       # [3.0] don't skip [0] initially
        self.master.geometry(before)


    def onColorList(self):
        """
        --------------------------------------------------------------------
        pick next color pair in configurable list (color cycling, manual)
        --------------------------------------------------------------------
        """
        self.text.config(fg=self.colors[0]['fg'],
                         bg=self.colors[0]['bg'])
        # [3.0] cursor=fg, else lost in dark bg
        self.text.config(insertbackground=self.colors[0]['fg'])
        self.colors.append(self.colors.pop(0))     # move current front to end


    @modalMenuAction
    def onPickFg(self):
        """
        --------------------------------------------------------------------
        open platform's color-select dialog to pick arbitrary fg
        --------------------------------------------------------------------
        """
        self.pickColor('fg')                       # added on 10/02/00


    @modalMenuAction
    def onPickBg(self):
        """
        --------------------------------------------------------------------
        open platform's color-select dialog to pick arbitrary bg
        --------------------------------------------------------------------
        """
        self.pickColor('bg')                       # this is too easy?


    def pickColor(self, part):
        """
        --------------------------------------------------------------------
        set foreground or background color per user input
        [3.0] pass parent to avoid raising root on Windows;
        this does not invoke a slide-down on Mac OS X here;
        --------------------------------------------------------------------
        """
        names = dict(bg='Background', fg='Foreground')
        partname = names[part]
        prompt = 'PyEdit - Pick %s' % partname     # [3.0] custom prompt

        # platform-specific dialog
        (triple, hexstr) = askcolor(parent=self,   # don't raise Tk root  
                                    title=prompt) 
        dlgRefocus(self)                           # [3.0] else Mac needs click
                
        if hexstr:
            self.text.config(**{part: hexstr})
            # [3.0] cursor=fg, else lost in dark bg
            if part == 'fg':
                self.text.config(insertbackground=hexstr)


    def onPickFont(self):
        """
        --------------------------------------------------------------------
        2.0: open new non-modal custom dialog to pick arbitrary font for self
        2.1: pass per-dialog inputs to callback, may be > 1 font dialog open
        [3.0] total rewrite to provide help and meaningful prefills
        [3.0] note: there is a new font dialog in Tk 8.6+, but can't assume;
        [3.0] on Mac, set default app menubar for nonmodal dialogs, else erased;
        [3.0] caveat: dialog not updated if zoom in/out, but unclear if should;
        [3.0] hide while build, else flash on Windows (due to currentFont()?);
        [4.0] hide policy added to Grep/Run/Change: they flash on Win too; 
        --------------------------------------------------------------------
        """
        from PP4E.Gui.ShellGui.formrows import makeFormRow
        
        popup = Toplevel(self)          # pertains to and closed with self
        popup.withdraw()                # [3.0] hide to avoid flash
        try_set_window_icon(popup)      # [3.0] icons where supported
        popup.title('PyEdit - Font')
        #popup.resizable(width=False, height=False)  # [3.0] nonresizable: help

        # [3.0] call guimaker's Mac menubar fixer for nonmodal dialog window
        fixAppleMenuBarChild(popup)

        # [4.0] on Android, limit size but don't expand
        self.limitWindowToScreen(popup)

        # [4.0] don't allow vertical resizes: Help won't reappear
        popup.resizable(width=True, height=False)

        # [4.0] focus=True saves the user a click
        var1, ent1 = makeFormRow(popup, 
                   label='Family', browse=False, width=18, focus=True)
        var2 = makeFormRow(popup, 
                   label='Size',   browse=False, width=18)
        var3 = makeFormRow(popup, 
                   label='Styles', browse=False, width=18)
        
        # [3.0] prefill with current font: see also preset pick-list's examples
        family, size, style = self.currentFont()
        var1.set(family)
        var2.set(size)
        var3.set(style)

        def onFontApply():
            # vars in per-call/dialog enclosing scope, not per-editor self
            self.onDoFont(popup, var1.get(), var2.get(), var3.get())
                          
        btnfrm = Frame(popup)
        btnfrm.pack(side=TOP)
        abtn = Button(btnfrm, text='Apply', command=onFontApply)
        abtn.pack(side=LEFT)
        popup.bind('<Return>', lambda event: onFontApply())   # [3.0] Enter=Apply

        # [3.0] add usage help hints pulldown (dialog-specific: not a popup)
        # [4.0] reformat from fixed layout to word-wrap to dialog window size 

        helptext = (
        'This dialog sets the font of the text displayed by the window that opened it.  '
        'Its input fields are prefilled with the font parameters currently being used.  '
        'Enter Family, optional Size, and a space-separated list of zero or more Styles.'
        '\n\n'
        '%(dialogHelpBullet)s Family:'
        '\n\n'
        'Use courier, times, helvetica, arial, consolas, calibri, inconsolata, menlo, etc.  '
        'Some family names may render differently or map to a default on some platforms.  '
        'Courier, helvetica, and times are guaranteed to be present on every platform.  '
        '\n\n'
        'For fixed-width text like program code, try menlo or monaco on Macs, consolas '
        'on Windows, inconsolata on Linux, or courier on all three (as well as Android, '
        'where the Pydroid 3 app uses non-monospace helvetica for unsupported fonts).  '
        'A font.families() in a running Python/tkinter program lists all available font '
        'families.'
        '\n\n'
        '%(dialogHelpBullet)s Size:'
        '\n\n'
        'Use 9, 12, 18, 20, 0, -30,... '
        'where N=points, -N=pixels, 0=platform default, and empty=0.'
        '\n\n'
        '%(dialogHelpBullet)s Styles:'
        '\n\n'
        'Use any of (bold or normal), (italic or roman), underline, or overstrike.  '
        'Default values are normal (i.e., nonbold) and roman (i.e., nonitalic).'
        '\n\n'
        'Example inputs of family, size, and style '
        '(do not input brackets or quotes added here for clarity only):'
        '\n\n'
        '["arial", "9", ""]\n'
        '["courier", "12", "bold"]\n'
        '["monaco", "12", "normal"]\n'
        '["times", "0", "normal italic"]\n'
        '["courier new", "-20", "bold roman underline"]'
        '\n\n'
        "Click Apply to apply the font parameters you have entered to the edit window text.  "
        'The Enter key also applies the font, and Escape opens or closes this help.  This '
        'dialog stays open on screen to allow you to experiment with alternative settings.  '
        '\n\n'
        "Save fonts in your program's config files to use them as presets in later runs.  "
        "See also the View menu's Font List to cycle through your preset fonts on request, "
        "and its Zoom In/Out to increment and decrement the current font's size quickly.  "
        "To set the Run Code output window's font, see its textConfig.py setting."
        ) % globals()

        self.addDialogHelp(popup, btnfrm, helptext)    # see grep, Escape=Help
        popup.deiconify()                              # [3.0] unhide flash-free; yes, 3.0
        ent1.focus_set()                               # [4.0] after deiconify on Windows


    def onDoFont(self, popup, family, size, style):
        """
        --------------------------------------------------------------------
        on Apply in nonmodal font input dialog: configure text;
        self is the same edit window here, for open pick-font dialogs;
        size seems the only required part (style default=normal+roman);

        [4.0] Should this avoid changing window size along with the font, 
        like the newest zoom (onFontPlus, onFontMinus)?  For variety, it 
        currently does not - unless size was changed by user or zoom...
        --------------------------------------------------------------------
        """
        before = self.master.geometry()
        if size == '':
            size = '0'   # use default size if omitted [3.0]
        try:
            self.text.config(font=(family, int(size), style))
        except:
            my_showerror(self, 'Font', 'Bad font specification')
            popup.focus_force()   # [3.0] raise, refocus on Mac
        self.master.geometry(before)


    def onLineWrap(self):
        """
        --------------------------------------------------------------------
        [3.0] toggle line wrapping in the edit window's text on or off;
        it's off by default with a horizontal scroll bar; when toggled
        on here, use character boundaries only - 'word' boundaries seem
        too much formatting; Run Code's output window similarly toggles,
        but must set up an Escape binding manually (it has no menu);

        UPDATE: this is now a 3-state toggle, that cycles through none,
        char-wrapping, and word-wrapping.  Word wrapping seems prone to
        errors (your file may be one massive line!), but also may be
        useful when viewing unstructured prose with very long lines.
        Run Code still does just off and char: it is structured text.
        --------------------------------------------------------------------
        """
        wrapmodes = ['none', 'char', 'word']          # Tk's options
        self.textwrapped += 1                         # starts at 0=none
        nextmode = wrapmodes[self.textwrapped % 3]    # remainder of div
        self.text.config(wrap=nextmode)               # none->char->word




    ############################################################################
    # Tools menu commands
    ############################################################################


    @modalMenuAction
    def onInfo(self):
        """
        --------------------------------------------------------------------
        pop-up dialog giving text statistics and cursor location;
        caveat (2.1): Tk insert position column counts a tab as one 
        character: translate to next multiple of 8 to match visual?
        note: 3.X len(text) is chars (Unicode codepoints), not bytes;
        [3.0] new format; add font, color, modified, Unicode encoding;
        --------------------------------------------------------------------
        """ 
        text  = self.getAllText()                  # added on 5/3/00 in 15 mins
        chars = len(text)                          # words uses a simple guess:
        lines = len(text.split('\n'))              # any separated by whitespace
        words = len(text.split())                  # 3.x: bytes is really chars:

        chars = format(chars, ',d')                # str is unicode code points
        lines = format(lines, ',d')                # [3.0]: comma-separate Ks
        words = format(words, ',d')
        
        index = self.text.index(INSERT)            # Tk insert location: 'line.col'
        line, col = index.split('.')               # ('line', 'col')
        line, col = (int(x) for x in (line, col))  # (line, col), Tk col 0 => 1
        col += 1                 
        where = tuple(format(x, ',d') for x in (line, col))
        
        font   = self.currentFont()                 # [3.0]: font, also onPickFont
        colors = self.text.cget('bg'), self.text.cget('fg')

        # f-string me someday?
        my_showinfo(self, 'Information',
                 '—Current Location—\n' +
                 'line:  \t%s\ncolumn:\t%s\n\n' % where +
                 '—Text Statistics—\n' +
                 'lines:\t%s\nchars:\t%s\nwords:\t%s\n\n' % (lines, chars, words) +
                 '—Unsaved Changes—\n' +
                 '%s\n\n' % bool(self.isModified()) +
                 '—File Encoding—\n' +
                 '%s\n\n' % self.knownEncoding +
                 '—Display Font—\n' +
                 '%s, %s, %s\n\n' % tuple(font) + 
                 '—Display Color—\n' +
                 'bg: %s, fg: %s' % colors)


    def onPopup(self):
        """
        --------------------------------------------------------------------
        [3.0] added to allow main Tk windows(s) to create transitory
        Toplevel windows that can be closed individually without closing
        other windows, and are not closed with the spawning self window;
        else Clone for a main Tk can make only other Tk windows that all
        close whenever any one of them is closed; in sum:

        -File->New opens a new file in the same window
        -Tools->Clone makes a new window of same type as opener (Tk or Toplevel)
        -Tools->Popup (new) makes a new transient (Toplevel) window

        naturally, users can also simply click their PyEdit shortcut or alias
        again, which creates a truly-independent window, session, and process;
        caveat: Popup is the same as Clone for Toplevel popup windows;

        [4.0] moot for lockfiles because popup window comes up empty: like 
        Clone, user must Open to open a file in the popup (or Save, etc). 
        --------------------------------------------------------------------       
        """
        TextEditorMainPopup(winTitle='Popup')     # parent=None=Tk root (not self)


    def onClone(self, makewindow=True):                  
        """
        --------------------------------------------------------------------
        open a new edit window without changing one already open (onNew);
        inherits quit and other behavior of the window that it clones;
        2.1: subclass must redefine/replace this if makes its own popup, 
        else this creates a bogus extra window here which will be empty;
        e.g., TextEditorMainPopup redefines to pass makewindow=False, but
        main windows make a new Toplevel with parent=implicit Tk app root;
        either way, child of default Tk not self, so not closed with self;

        [4.0] moot for lockfiles because clone window comes up empty: like 
        Popup, user must Open to open a file in the clone (or Save, etc). 
        Also, Clone should probably be wholly removed soon; it's an oddity.
        --------------------------------------------------------------------       
        """
        if not makewindow:
             new = None                 # assume class makes its own window
        else:
             new = Toplevel()           # a new edit window in same process
        myclass = self.__class__        # instance's (lowest) class object
        myclass(new)                    # attach/run instance of my class


    def onRunCode(self):
        """
        -------------------------------------------------------------------------
        [3.0]: Open new non-modal custom dialog to run code text in window self.
        This replaces the former multiple-popup interface, and adds a new option
        for capturing the code's standard streams in the PyEdit GUI interface,
        by spawning a thread to poll for the code's output and post on receipt,
        and allowing the GUI user to enter input to be sent to code on request.

        The newer Capture mode uses Python's subprocess to tap into the code's
        streams (multiprocessing, used for grep, is for passing data instead).
        This and other custom dialogs have no Cancel: simply close the window.
        See the dialog's help text below for more on this command's utility.
        -------------------------------------------------------------------------
        """
        popup = Toplevel(self)            # pertains to and closed with self
        popup.withdraw()                  # [4.0] hide to avoid flash on Windows
        try_set_window_icon(popup)        # icons where supported
        popup.title('PyEdit - Run Code')
        #popup.resizable(width=False, height=False)  # need resizes for cmd args

        # [3.0] call guimaker's Mac menubar fixer for nonmodal dialog window
        fixAppleMenuBarChild(popup)       # Mac menubar fixer for dialogs

        # [4.0] on Android, limit size but don't expand
        self.limitWindowToScreen(popup)

        # [4.0] don't allow vertical resizes: Help won't reappear
        popup.resizable(width=True, height=False)

        argsfrm = Frame(popup)
        argsfrm.pack(side=TOP, fill=X)
        Label(argsfrm, text='Command-line arguments?', relief=RIDGE).pack(side=LEFT)
        cmdargs = Entry(argsfrm, width=30)
        cmdargs.pack(side=RIGHT, expand=YES, fill=X)

        radiofrm = Frame(popup, relief=GROOVE, border=3)
        radiofrm.pack(fill=X, padx=5, pady=5)
        Label(radiofrm, text='Run Mode:').pack(side=TOP, anchor=W)

        # sans propr String: in-process is too dangerous
        # Keep is special only on Windows: popup info if used elsewhere
        # Console requires Python config for Windows/Linux frozen exec (on Py!)
        modevar = StringVar()

        # [4.0] drop hieroglyphics (caution: these strings are used ahead)
       #modes = ['Console ⚕', 'Click', 'Click+Keep', 'Capture ⚕'] 
        modes = ['Console (Python)', 
                 'Click (All)', 
                 'Click+Keep (Windows)', 
                 'Capture (Python)']     
    
        for mode in modes:
            Radiobutton(radiofrm,
                        text=mode,
                        variable=modevar,
                        value=mode, pady=3).pack(side=TOP, anchor=NW)
        modevar.set(modes[-1])

        def onRun():
            self.onDoRunCode(popup, cmdargs.get(), modevar.get())                          

        btnfrm = Frame(popup)
        btnfrm.pack(side=TOP)
        Button(btnfrm, text=' Run ', command=onRun).pack(side=LEFT)
        popup.bind('<Return>', lambda event: onRun())   # Enter=Run

        # [3.0] add usage help hints pulldown (dialog-specific: not a popup)
        # [4.0] reformat from fixed layout to word-wrap to dialog window size 

        helptext = (
        "This dialog launches Python (or other) code.  It assumes that the text in the "
        "window you open it from is either a Python program or other launchable content, "
        "and runs the code with optional command-line arguments in a selected run mode."
        "\n\n"
        "Run Code turns PyEdit into an edit+run development tool.  It is not a full IDE, "
        "but can be used to test and run programs and other content you code in PyEdit, "
        "without resorting to shell command lines or other external tools."
        "\n\n"
        "USAGE"
        "\n\n"
        "This dialog window stays open to allow you to run edited code multiple times.  "
        "Select a run mode from its list; Capture mode is generally recommended for most "
        "Python code.  All run modes run your code from its file, and prompt you when a "
        "Save is required for new files or changes."
        "\n\n"
        "Enter command-line arguments, if used by the code, at the top of this window, "
        "and click Run (or press Enter) to launch the code in the associated edit window.  "
        "Run Code supports shell syntax for arguments, and quotes or escapes the names of "
        "your file and the Python executable (if used) as required for the host platform.  "
        "Depending on the run mode used, any console IO interaction will occur in either "
        "a system console window or PyEdit's own GUI, per the run-mode details below."
        "\n\n"
        "RUN MODES"
        "\n\n"
        "All run modes start the code's file in a new process so PyEdit is not paused or "
        "shut down early.  They differ in their assumptions about the code's type, and "
        "in their handling of the code's console IO streams:"
        "\n\n"
        "%(dialogHelpBullet)s Console (Python):"
        "\n\n"
        "On all platforms, this mode assumes the window's text is Python code, and "
        "routes its console IO (if any) to the console window used to start PyEdit "
        "(if any).  It runs the code with either the Python running PyEdit, or one "
        "you've installed locally and set in your textConfig.py configurations file.  "
        "Because this mode pops up no additional windows, it may work well for GUIs."
        "\n\n"
        "Limitations: although this mode can be used to start many types of programs, "
        "it does not work well for code that uses console IO streams when no console "
        "exists (e.g., print() and input() go nowhere when PyEdit is launched by icon "
        "click).  This mode is also unavailable when PyEdit is a frozen Windows or "
        "Linux executable, unless your textConfig.py sets an installed Python's path.  "
        "Import-path settings in your textConfig.py are ignored; use PYTHONPATH where "
        "available (e.g., when PyEdit is launched from a console on macOS), or use "
        "Capture mode below for more control over streams and paths."
        "\n\n"
        "%(dialogHelpBullet)s Click (any code):"
        "\n\n"
        "On all platforms, this mode assumes the window's text is Python code or any "
        "other launchable content, and runs the code's file as though its icon was "
        "clicked in the platform's file-explorer GUI.  This mode can be used for both "
        "Python programs and non-Python code being edited (e.g., HTML files may open "
        "in a web browser).  For Python code, it uses whatever Python you associate "
        "with the file or its type, and on Windows may open console windows to serve "
        "as the code's standard streams."
        "\n\n"
        "Limitations: this mode is platform-specific.  Because it does not connect to "
        "the code's IO streams explicitly, it can fail for code that uses them on "
        "some platforms.  This mode will also fail if no program has been associated "
        "to open the code's file on your computer; for Python code this must normally "
        "be a Python which you have installed locally.  Unlike Console and Capture, "
        "this mode also cannot pass command-line arguments to Python code scripts on "
        "some platforms (e.g., macOS), though no-argument scripts work more portably.  "
        "This mode ignores Python and import-path settings in your textConfig.py; "
        "set your associations to change your Python, and set PYTHONPATH where used."
        "\n\n"
        "%(dialogHelpBullet)s Click+Keep (any code, Windows only):"
        "\n\n"
        "On Windows, this mode is the same as Click, but opens a new Command Prompt "
        "window for the code's console IO, which remains open after the code exits so "
        "no closing input() call is required in Python code.  On Unix (macOS, Linux, "
        "Android), this mode is not available; use one of the other modes to launch code."
        "\n\n"
        "%(dialogHelpBullet)s Capture (Python, recommended):"
        "\n\n"
        "On all platforms, this mode assumes the window's text is Python code, and "
        "connects the code's console IO to PyEdit's GUI.  The code's standard output "
        "(e.g., print()) plus any standard error (e.g., exceptions) are scrolled by "
        "PyEdit in a per-run window.  Standard input (e.g., for input()) is provided "
        "for the code as needed: type an input line at the top of the run's window "
        "and press Enter or Send.  This mode works for all code on all platforms; it "
        "is ideal when PyEdit is started without a console window (e.g., by a click) "
        "and is recommended unless no console IO is used or a console is present."
        "\n\n"
        "Normal spawned-program exit disables the input line at the top of the run's "
        "window, and closing the run's window forcibly kills the spawned program if "
        "it is still running.  Kills allow you to shutdown programs that are looping "
        "or no longer pertinent, and avoid programs becoming hung waiting for input.  "
        "Capture mode also kills any still-running spawned programs when PyEdit itself "
        "is closed, to avoid pipe errors; launch longer-lived programs in other ways."
        "\n\n"
        "Limitations: none, though this mode may require configurations when PyEdit "
        "is a frozen app or executable.  It runs code with either a Python given in "
        "your textConfig.py, or else the Python used to run PyEdit.  It also uses the "
        "module import-path settings in your textConfig.py to allow locally-installed "
        "libraries to be used when PyEdit is a frozen product; if no such setting is "
        "given in this context, imports might be limited to Python's standard library "
        "modules.  This mode may also scroll output slower than a console on some "
        "platforms; its output window may be extraneous but harmless for GUIs; and "
        "it supports but does not hide passwords input via Python's getpass module."  
        "\n\n"
        "Tips: in the run's output window, use Ctrl/Command+C to copy selected text; "
        "Ctrl/Command+A or Click/Shift+Click to select all text (e.g., to paste into "
        "a full PyEdit Popup window); and the Escape (Esc) key to toggle output-text "
        "line-wrapping on and off.  See README.txt for more package-related notes."
        "\n\n"
        "CONFIGURATION"
        "\n\n"
        "Both Console and Capture modes allow you to configure the Python used to run "
        "your code, by setting its path in your textConfig.py file.  The Python 3.X (and "
        "its standard library) that is running PyEdit is used by default, but any other "
        "separately-installed Python may be used — including a Python 2.X.  Click modes "
        "instead use your computer's file/type associations to choose a Python."
        "\n\n"
        "Capture mode also allows you to extend the module-import path to include your "
        "local code or installs folders, though this is not required to use modules in "
        "either your main script's folder or Python's standard library, even for PyEdit "
        "apps and executables.  For more details, see the documentation in textConfig.py."
        "\n\n"
        "As of PyEdit 4.0, it's more important to set a path to a locally installed "
        "Python in textConfig.py because 4.0 reduces code startup times by no longer "
        "baking in a complete Python standard library.  Setting this path also enables "
        "local Python extensions and speeds startup further."
        "\n\n"
        "EXAMPLES"
        "\n\n"
        "For precoded examples you can try in Run Code, see the files and README.txt in "
        "PyEdit's install folder docetc/examples/RunCode-examples."
        "\n\n"
        "ANDROID NOTE"
        "\n\n"
        "PyEdit's Run Code works generally well on Android mobiles, but is unable to run "
        "Python code that uses the tkinter GUI library.  This stems from a design choice "
        "of the underlying Pydroid 3 app used to run PyEdit on Android (in short, tkinter "
        "GUIs cannot run tkinter GUIs because tkinter usage must be detected by the app's "
        "IDE).  Run tkinter code from Pydroid 3's IDE instead." 
        ) % globals()

        self.addDialogHelp(popup, btnfrm, helptext)    # see grep, Escape=Help
        popup.deiconify()                              # [4.0] unhide flash-free
        cmdargs.focus_set()                            # [4.0] save click, Win post deicon
        

        
    def onDoRunCode(self, popup, cmdargs, runmode):
        """
        -------------------------------------------------------------------------
        [3.0] On Run in RunCode dialog: launch this window's text as code.  
        Run as clicked program, spawned process with or without console, 
        or spawned process with standard streams (console IO) capture.
        
        The latter--Capture mode--is preferred.  It uses a reader thread with
        an after() timer output-polling loop to avoid blocking the GUI, and 
        each run gets its own popup whose close will kill the code if running.
        This isn't the sole mode, because scrolling is slow on Macs in the Tk
        used for development, and Click mode has valid use cases (e.g., HTML).  
        See the the onRunCode() GUI builder above for additional details.

        Subtlety: the PyEdit launcher script "Launch_PyEdit.pyw" shipped
        with PyMailGUI uses a wait() call to stay open until PyEdit exits.
        This is required to keep PyEdit's streams usable for any code PyEdit
        runs here.  Else, the code's grandparent (launcher) stdin stream
        reports EOFError (or OSError) immediately in terminals on Unix, for
        code using input in modes String, Streamless, and Console.  This is
        not an issue for Capture mode which works without wait() too (yet
        another reason to prefer it), or when textEditor.py is run directly,
        though closing PyMailGUI's launcher can trigger the issue too (rare!). 

        Update: the original string mode ("String" in this version) has been
        withdrawn from the GUI.  It leads to issues when the GUI is unblocked
        while code runs, and can cause PyEdit to be closed without save prompts
        if the code spawned is either a GUI that quits or any code that exits.  
        Generally, spawned code must be run in a separate process to insulate 
        PyEdit from the code's errors and exits; the three remaining Run Code
        modes do so, at the minor expense of requiring code to be saved in files.
        Most of String mode's original code was moved to a doc file: see ahead.
        A prior Streamless mode has also been cut; use Console on Windows.

        -------------------------------------------------------------------------
        About the input() replacements:

        [See also above: String mode has now been withdrawn.  Capture modes uses
        a proxy script; it was originally designed to replace the built-in input()
        with a version that flushes its prompt as described here, but has since
        grown to perform additional tasks; see notes ahead at Capture mode's code.]

        1) String mode requires an input() replacement, because the builtin
        version releases control to the GUI while waiting for input.  This 
        has to do with Python's input hook function (PyOS_InputHook), which
        oddly is coded to trigger Tk's event loop too when tkinter is used.
        The net effect is that Tk Guis are normally blocked for paused or 
        long-running actions--but NOT for an input() that is waiting for text.
      
        This isn't a concern for sys.stdin.realine() (which is blocking) or 
        other run modes (which run in separate processes).  It matters for 
        String mode, because the CWD is reset while the target code runs.
        This can make auto-save misroute CWD-based save files if its after() 
        events can fire during a paused input(), and can break Help's image
        and HTML paths if its button remains active.  To fix, we could either
        save directories at start-up instead of fetching as needed, or replace
        the built-in input() with one that is blocking; the latter was used.

        Note that this is not an issue for sys.stdin.readline() calls in code
        run by String mode: the GUI is blocked until input is entered in the 
        console--as normal.  Also note that all other Run Code run modes are 
        immune to this issue, because they run code in a separate process (and
        are probably preferred for that reason; String mode is a legacy tool).

        2) Capture mode also defines a custom input() replacement, via code in 
        file subprocproxy.py.  This replacement is not to force blocking, but 
        is required to force input() to flush its prompt with a newline before 
        reading; else prompts would appear _after_ user input is required.

        -------------------------------------------------------------------------
        About the (*now withdrawn*) String mode:
        
        1) On further testing, input() redefinition does _not suffice to keep 
        the GUI blocked in all cases.  This is true even if the custom version
        is injected into the builtin scope.  The source of the GUI event-loop
        restart may be any, but GUI code that runs a nested mainloop() call 
        suffices to wreak havoc.  Hence, String mode is prone to odd behavior 
        when it should wait for the run code to exit but does not.  This 
        merits a punt for now; other modes are recommended.

        2) String mode code also uses PyEdit's GUI event loop and root widget.
        Building more widgets may add to PyEdit's root, and a widgets.quit()
        may shut down PyEdit (without a prompt for unsaved changes!).  Don't
        do this.  String mode, if used at all, should be for non-GUI programs.
        [String mode was later withdrawn for this reason: it's too dangerous.]
        
        -------------------------------------------------------------------------
        Console mode alternatives

        In Console mode, explored starting new console/terminal windows in this
        mode on _all_ platforms, and _never_ if all 3 standard streams are TTYs
        (if their .isatty() is True).  It seems overkill to open a new console
        window on Windows with Start if one is already present, and the code's
        streams are inoperative on Unix in this mode when no terminal exists.

        This was abandoned, because it makes for behavior that seems uneven
        (a new console might appear or not, depending on how PyEdit was run),
        and there seems no usable way to open a new terminal on Unix to run a
        Python script with command-line arguments, leaving this per-platform.
        "open -a Terminal stuff.py" is almost there on Mac, but script cmd args
        fail; "gnome-terminal" may work on some Linux, but may not work on all.
        Capture mode works the same and everywhere => it's the recommended mode.

        Console mode could also fallback on using Capture-like Popen calls but
        not catpuring streams, but this was deemed moot: use Capture mode if
        there is no Python executable present or configured.

        -------------------------------------------------------------------------
        Frozen app and executable notes
        
        Frozen apps/executables throw a monkey-wrench into the RunCode design,
        because they ship with a fixed set of frozen library models (for both
        Mac apps and Windows/Linux exes), and may ship with no Python executable
        at all (for Windows/Linux exes).  Moreover, one of the features of
        frozen programs the that they never require a separate Python install.
        Requiring a Python install may be reasonable for running code, but it's
        a bit much for casual users.  How to run arbitrary user Python code?

        This was resolved by forced-inclusion of all (or most?) standard libs
        in the freezes for basic use, and allowing the textConfig.py file to 
        specify both a Python executable when one is preferred or required, and
        import-path settings to pick up different or locally-installed items.

        For exes on Windows and Linux, the Capture mode's proxy script also
        must be frozen, because there may be no standalone Python executable.
        In this case only, a Python executable _must_ be configured for modes
        that require one for running user code -- namely, Console mode only.
        For capture mode, _both_ the .py and frozen versions of the proxy are
        shipped: the former is used when textConfig.py names an installed
        Python, and the (more limited) frozen proxy is used otherwise.

        -------------------------------------------------------------------------
        Killing spawned scripts: 

        As a new feature, Capture now forcibly kills spawned programs when 
        their run window is closed by the user so the programs don't live on
        indefinitely.  This is important when the program is waiting on input
        from PyEdit, but is especially useful for code stuck in an infinite loop.
        It's also platform-specific and complex.  This is especially so when 
        using subprocess's shell=True (a kill may kill the shell parent, not its 
        proxy-command child), but this setting is required for other reasons here 
        (e.g., allowing arbitrary command-line args without parsing).

        On Mac:
           Popen's kill() does the trick, without manually-formed process groups.

        On Windows:
           Kills require running a "taskkill /f /t" command to force-kill the
           shell process by its pid, and all processes it started (including the
           proxy).  Setting shell=False with a cmd string sufficed in some 
           contexts but not all (e.g., frozen Windows executables), and the Popen
           CREATE_NEW_PROCESS_GROUP is not required to make this work.

           taskkill causes momentary console popups in frozen Windows PyEdits only,
           unless this command is run with subprocess.Popen() and shell=True as
           done here.  This forces use of STARTF_USESHOWWINDOW and SW_HIDE; passing
           Popen creationflags=CREATE_NO_WINDOWS (0x08000000) may work too (untried).
           os.system() did popups; os.popen() broke kills; os.spawnv() was untried.
           
           Windows process groups may be a cleaner solution for kills (and are
           used for Linux), but did not work at all despite multiple tries for the
           use case here - a stack that may include python, subprocess, cmd.exe,
           and Windows APIs, and can go bad anyhere along the way.

        On Linux:
           Kills require special code to create a process group at launch and 
           kill the entire group on window close, else only the shell is killed,
           not the proxy child it launches.  Using an "exec " cmd prefix to 
           replace the shell with its child also works, but only for source-code
           proxies (not frozen).  Neither of these are required on Mac, for 
           reasons that remain a suggested exercise (automatic groups?)
              
        A portable and alternative (but unverified) fix requires the 3rd-party
        psutil package to walk and kill child processes, and was not used here.
        Windows and Linux frozen PyInstaller proxies additionally must arrange
        for pruning of their temporary folders on non-normal exit; see ahead.
 
        In the end, Python's subprocess module is really two very-different
        and platform-specific interfaces.  While it helps with stream captures,
        it also adds an extra layer of wrapper code which may obscure platform
        interfaces too much, and is hardly a replacement for all prior art. 

        UPDATE: still-running spawned programs also have to be killed when 
        PyEdit closes, or they die horrible SIGPIPE deaths; see onCloseWindow.

        -------------------------------------------------------------------------
        Prior version comments follow (most are still relevant):
        
        run Python code being edited--not an IDE, but handy;
        tries to run in file's dir, not cwd (may be PP4E root);
        inputs and adds command-line arguments for script files;

        code's stdin/out/err = editor's start window, if any:
        run with a console window to see code's print outputs;
        but parallelmode uses Start to open a DOS box for I/O;
        module search path will include '.' dir where started;
        in non-file mode, code's Tk root may be PyEdit's window;
        subprocess or multiprocessing modules may work here too;

        2.1: fixed to use base file name after chdir, not path;
        2.1: use StartArgs to allow args in file mode on Windows;
        2.1: run an update() after 1st dialog else 2nd dialog 
        sometimes does not appear in rare cases (at this writing);

        [3.0] notes: launchmodes adds sys.executable py to cmdline
        in filemode launches; its objects' args are label+cmdline;        
        verified on Mac OS X - run from Terminal to see prints;
        -------------------------------------------------------------------------
        """


        #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # onDoRunCode() starts here (on a "Run" in Run Code popup)
        #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

        
        import _thread, queue, subprocess, traceback, shlex, shutil
        from PP4E.launchmodes import Spawn, StartAny, Fork
        from PP4E.launchmodes import quoteCmdlineItem
        from tkinter.scrolledtext import ScrolledText


        if runmode == 'String':                             # run text string
            #-----------------------------------------------------------------
            # DEFUNCT: String mode has been withdrawn - stub example only.
            # in-process: locks PyEdit, IO=PyEdit console, GUI root=PyEdit's;
            #
            # redefines built-in input() for the code run, because builtin 
            # version reactivates Tk event loop (it is not truly blocking):
            # this can misroute auto-saves and break Help icon and html file;
            #
            # see above for other issues: GUI code's mainloop() can also 
            # unblock GUI, and a double quit can close PyEdit silently (!); 
            #-----------------------------------------------------------------
            
            # moved to: doecetc/examples/Assorted-demos/trimmed-string-mode-code.py
            assert False, 'too dangerous: GUI may unblock, code may exit!'



        # -------------------------------------------------------------------
        # try parallel modes: these require a file, but do not block PyEdit
        # 
        # QUOTE (or escape) python-exe and edited-file paths for use in
        # command lines; shlex does not work on Windows, but for string-based
        # cmdlines its split() isn't needed and its 3.3+ quote() applies to
        # non-inputs here only; on Windows, quote python and file (naively)
        # to allow for spaces and specials, but not embedded quotes (if these
        # are legal at all); some modes do not need to quote python (Click
        # doesn't use it, Console doesn't add it to cmd), but must still
        # quote filename to allow nested spaces and specials;  UPDATE: all
        # quote code moved to PP4E.launchmodes.quoteCmdlineItem() for reuse;
        # -------------------------------------------------------------------

        # edited file: now always an absolute+normalized pathname
        thefile = self.getFileName()

        # is file usable?
        if thefile == None or not os.path.exists(thefile):
            my_showinfo(self, 'Run Code', 'File missing: you must Save before Run')
            return
        
        if self.text_edit_modified():                 # 2.0: changed test
            # [3.0] error -> info
            my_showinfo(self, 'Run Code', 'Text changed: you must Save before Run')
            return

        # user's preferred Python: overrides PyEdit's Python if set+valid
        userpython = Configs.get('RunCode_PYTHONEXECUTABLE', None)
        if userpython and not os.path.isfile(userpython):
            userpython = None

        # a python, when present/needed
        # [4.0] Android's Pydroid 3 app now sets sys.executable too 
        pickpython = userpython or sys.executable     # user's version or mine/me

        # quote for shell commands per notes above
        quotethefile = quoteCmdlineItem(thefile)      # quote for cmd as needed 
        quotepython  = quoteCmdlineItem(pickpython)   # enclosing spaces+specials

        # no python for source code: must use frozen proxy exe?
        # [4.0] sys.frozen is True (not py2app's string 'macosx_app')
        # in newer PyInstaller macOS app (just like it is on Windows)

        noPythonExe = (                     
            hasattr(sys, 'frozen')     and     # frozen exe PyEdit package?
            sys.frozen != 'macosx_app' and     # not Mac py2app (has a python)? 
            userpython == None)                # and no user python config?



        if runmode == 'Console (Python)':
            #-----------------------------------------------------------------
            # parallel: IO to Pyedit console (if any) on both Windows+Unix;
            # works if Pyedit run from cmdline, or no IO used (e.g., GUI);
            # chdir() may not be required on all platforms: just in case;
            #
            # NOT AVAILABLE ON WINDOWS OR LINUX FOR FROZEN EXECUTABLES,
            # unless user has set a Python install path in textConfig.py:
            # there may be no python exe, and target cannot be frozen here;
            # could mimic Capture mode and just not connect streams to the 
            # GUI, but that's too much effort for a less-convenient mode;
            #
            # a former "Steamless" mode that used os.P_DETACH was deleted
            # here, because it was redundant with Console mode on Windows;
            #-----------------------------------------------------------------

            # or remove from options list (tbd)
            if noPythonExe:
                # [4.0] + \n and macos
                my_showinfo(popup, 'Run Code',
                    'Sorry — Console mode is not available in standalone PyEdits '
                    'unless you give a locally installed Python\'s path in your '
                    'textConfig.py file.\n\nTry running your code with one of '
                    'the other listed modes.')
                return

            mycwd = os.getcwd()                             # cwd may be root
            dirname, filename = os.path.split(thefile)  
            os.chdir(dirname or mycwd)                      # cd for filenames

            label  = '[PyEdit: Run Code]'                   # separate output
            thecmd = quotethefile + ' ' + cmdargs           # 2.1: not theFile
            # now uses subrocess to avoid cmdline splits
            try:                                            # 2.1: support args
                Spawn(label,                                # run in parallel
                      thecmd,                               # user's py or mine
                      python=pickpython)()
            finally:
                os.chdir(mycwd)                             # go back to my dir



        elif runmode == 'Click (All)':
            #-----------------------------------------------------------------
            # parallel: IO to nowhere explicitly, run as if clicked in a
            # file-explorer on host platform, per file/type association;
            # may fails if no assoc prog, or standard input is required;
            #
            # this opens non-Python files too, and doesn't use an explicit
            # Python executable itself - opens per file/type associations;
            # arguably stretches Run Code paradigm, but handy for html, etc.
            #
            # caveat: Mac's "open" command run here does not pass arguments
            # to a Python script (they go to the PythonLauncher app instead),
            # Click is still useful for other apps and no-arg Python scripts;
            #-----------------------------------------------------------------

            # [4.0] do something marginally useful on Android - but not really;
            #
            # intially spawned an "am" activity-manager view-intent command line,
            # which uses Android default apps that are less general than other
            # platforms' filename associations, but we can't do any better
            # (the 'xdg-open' Linux command used otherwise won't work at all);
            #
            # Update: file:// URLs don't generally work on Android anymore,
            # and won't work in Python's webbrower.open() either => PUNT

            if RunningOnAndroid:
                return
                """CUT
                brw = 'am start --user 0 -a android.intent.action.VIEW -d %s'
                url = 'file://' + thefile
                cmd = brw % url
                os.system(cmd)    # not os.environ['BROWSER']
                CUT"""

            mycwd = os.getcwd()                             # cwd may be root
            dirname, filename = os.path.split(thefile)  
            os.chdir(dirname or mycwd)                      # cd for files

            label  = '[PyEdit: Run Code]'                   # separate output
            # quoting and cmdline now handled in StartAny
            try:
                StartAny(label,
                         thefile,
                         cmdargs)()                         # noPy used here
            finally:
                os.chdir(mycwd)                             # go back to my dir



        elif runmode == 'Click+Keep (Windows)':
            #-----------------------------------------------------------------
            # parallel: IO to new console on Windows, Pyedit console on Unix;
            # on Windows, the new console stays up after the program exits,
            # which spares the user from adding a closing input() call;
            # on Unix, works the same as Console mode if used (see above);
            #
            # NOW AVAILABLE ON WINDOWS ONLY: same as Console mode on Unix,
            # and Unix terminal popup equivalent has proved elusive (above);
            # we could change Console to do Keep on Windows iff all 3 std
            # streams are not .isatty(), but Keep is not needed for GUIs;
            # this mode was formerly called "Popup" (old docs warning...);
            #-----------------------------------------------------------------

            # or remove from options list (tbd)
            if not RunningOnWindows:                        # Mac/Linux: punt
                my_showinfo(popup, 'Run Code',
                    'Sorry — Click+Keep mode is not available outside Windows.\n\n'   # [4.0] \n
                    'Try running your code with one of the other listed modes.')
                return

            mycwd = os.getcwd()                             # cwd may be root
            dirname, filename = os.path.split(thefile)  
            os.chdir(dirname or mycwd)                      # cd for files

            label  = '[PyEdit: Run Code]'                   # separate output
            # quoting and cmdline now handled in StartArgs
            try:
                if RunningOnWindows:                        # 2.1: support args
                    StartAny(label,
                             thefile,
                             cmdargs,
                             keep=True)()                   # noPy used here
                else:
                    # unused: placeholder for mac/linux equivalents tbd
                    thecmd = quotethefile + ' ' + cmdargs   # 2.1: not theFile
                    Fork(label,                          
                         thecmd,
                         python=pickpython)()               # user's py or mine
            finally:
                os.chdir(mycwd)                             # go back to my dir



        elif runmode == 'Capture (Python)': 
            #-----------------------------------------------------------------
            # [3.0] spawn code file as a parallel process and connect to its 
            # streams in PyEdit's GUI; scroll its stdout+stderr output in a
            # per-un window, and send stdin input to it on user request;
            #
            # PREFERRED: works everywhere for all code, console window or not;
            # only downside is an extra output window for GUIs with no output,
            # but this window still displays Python error messages, if any;
            #
            # this uses an output reader thread and polling loop for scrolling
            # output here; for code, it uses a proxy script to force input() 
            # to flush its prompts before reading, encode output to UTF8 and
            # binary form, extend import paths, send PyEdit the process's temp
            # dir in PyInstaller executable mode, and compile and exec() the
            # target code: see subprocproxy.py for the other half of the story;
            #
            # the proxy is run as source-code for source and mac app formats,
            # and always if the user gives a python executable path in configs,
            # but must also be frozen for Windows and Linux exe distributions,
            # because there is no python executable to be found in the exe; 
            # see build-app-exe/windows/build.py for more notes on this case;
            #
            # the proxy app/exe also "bakes in" most (all?) of Python's std
            # lib for use by the code; users can instead config a python exe 
            # (and hence std libs): see include-full-stdlib.py in same folder;
            #
            # tbd: input line is saved for context; clear it on send instead?
            # tbd: font is in textConfig, but change with general text font?  
            # tbd: this pops up a new RunCode window per Run click to retain 
            # prior cmdline args; or keep/lift just one per PyEdit window?
            #
            #-----------------------------------------------------------------
            # USE IN EMBEDDED CONTEXTS
            #
            # INITIAL POLICY: Capture will not be fully functional whenever
            # PyEdit is being used as an embedded component widget by another 
            # program (e.g., PyMailGUI), except for source-code distributions.
            # Instead, we issue a message pointing users to the full PyEdit 
            # download site.  This is largely due to implementation issues (it
            # seems odd to bake all stdlibs into an email client for a coding 
            # tool), but also for security (mixing code and email is a bad
            # idea).  Capture works fully in all _PyEdit_ standalone packages
            # (source, app, exes) as well as source-code form PyMailGUIs, but 
            # has minimal stdlibs/utility in PyMailGUI frozen app and exes.
            #
            # TBD TEMP: we could allow Run Code if the user has configured a 
            # Python exe; this may run into PYTHONPATH/HOME issues in PyMailGUI
            # app, and seems a bit too tricky for a rarely-used feature.
            #
            # FINAL POLICY: we now disable Run Code and issue a popup when 
            # PyEdit is an imported embedded component, in **all** run modes: 
            # source, frozen app, frozen exe.  Although Run Code works in 
            # source-code PyMailGUIs, and has only reduced stdlib support in
            # the Mac app PyMailGUI, running code in other programs like email
            # clients seems largely academic, if not invalid.  The last straw 
            # was the need to kill still-running programs on PyEdit quit: this
            # would add an extra exit task to embedders (along the lines of 
            # current unsaved-changes handling) that's not worth the effort. 
            #-----------------------------------------------------------------

            if __name__ != '__main__':
                # not standalone (main) in source, app, or exe contexts
                my_showinfo(popup, 'Run Code',
                    'Sorry — PyEdit\'s Run-Code Capture mode is not available '
                    'in this program.\n\nTo use Capture mode in its complete '    # [4.0] \n
                    'form, get the full standalone PyEdit program at:\n\n'
                    '    https://learning-python.com/pyedit')
                return   # run code not supported here

            """CUT
            delete me soon.....................................................
            # if not source code and not own PyEdit frozen app or exe
            # --or-- source code but part of a frozen macOS app (PyMailGUI);
            # __name__ == '__main__' won't help: ok if embed in source; 
            # sys.executable won't help: may be an app bundle python;

            if (RunningOnMacOS and 
                not hasattr(sys, 'frozen') and 
                '.app' + os.sep in os.getcwd()):
                runcodewarn_mac()                 # e.g., PyMailGUI Mac app
                # but continue
                
            elif (hasattr(sys, 'frozen') and 
                  not any('pyedit' in arg.lower() for arg in sys.argv[0:2])):
                if RunningOnMacOS:
                    runcodewarn_mac()             # other macOS app embedders?
                    # but continue
                elif RunningOnWindows or RunningOnLinux:
                    runcodepunt_winlin()          # PyMailGUI Windows/Linux exes
                    return                        # run code not supported here
            ...................................................................
            CUT"""


            #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
            # Capture mode utilities (some are enclosing-scope closures)
            #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


            # forced encoding for all three streams in spawnee
            StreamEncoding = 'UTF8'  


            def streamreader(stream, linequeue, EOF):
                """
                -------------------------------------------------------------
                In a parallel thread - read the subprocess's stdout/stderr 
                stream, and post its lines to a queue for the GUI to fetch 
                and display on timer-event callbacks; this way, the GUI is
                not blocked waiting for the spawned program's output lines.
                The thread exits on subproc stdout stream close (real eof),
                which is assumed to occur on both normal and forced exits.
                Stdout/stderr streams are binary: line reads work anyhow.

                [4.0] Nit: because this reads streams by lines, it cannot get
                individual characters sent and flushed by the spawnee.  LP6E's
                staggerred certificate print, for example, is not received and
                echoed in the GUI until the whole line is complete.  Meh?
                Reading and queueing individual characters is likely slow.
                -------------------------------------------------------------
                """
                for line in stream:           # may block this thread (only)
                    linequeue.put(line)       # place on queue for GUI timer loop
                linequeue.put(EOF)            # subproc exit: write sentinel, exit


            def streamconsumer(linequeue, EOF, textdisplay, inputline, inputsend):
                """
                -------------------------------------------------------------
                In the main GUI thread - run a timer-based loop to poll for, 
                fetch, and scroll lines from the shared thread queue until 
                the reader thread sends the EOF-signal sentinel on the queue.

                This timer loop runs only until a single program run finishes.
                it ends when the stream reader sends EOF, or the output window
                is closed; after() silently does nothing on destroyed windows
                (docetc/examples/*/demo-poll-silent-exit-on-window-close.py).

                Processes lines in batches for speed; this helps everywhere,
                but scrolling is still weirdly slow with AS's Mac Tk 8.5!
                Avoiding update() till N lines have been received may help,
                but makes scrolling jerky, and precludes interactive code.                
 
                Batches may also make it appear as if others are paused when
                running multiple programs - the latest's scrolls hog the cpu.
                Stdout/stderr streams are binary: decode + fix eolns for GUI.
                --------------------------------------------------------------
                """
                line = None
                while line != '[EOF]':
                    # process the next batch of posted lines
                    try:
                        queued = linequeue.get(block=False) 
                    except queue.Empty:
                        # nothing posted: go reschedule and wait
                        break

                    if queued is EOF:
                        # subproc exited: end loop, leave text window open
                        inputline.config(state=DISABLED)
                        inputsend.config(state=DISABLED)    # else broken-pipe errors
                        inputline.unbind("<Return>")        # need unbind: has focus       
                        line = '[EOF]'                      # display this line last
                    else:
                        # binary stream line: manually decode and fix eolns
                        try:
                            line = queued.decode(StreamEncoding)
                            line = line.replace('\r', '')   
                        except UnicodeDecodeError:
                            line = '(UNDECODABLE LINE)\n'

                    # process next line: add to PyEdit window, force GUI update
                    try:
                        line = fixTkBMP(line)               # sanitize Unicode for gui
                        textdisplay.config(state=NORMAL)    # allow changes temporarily
                        textdisplay.insert(END, line)       # add to end of text widget
                        textdisplay.see(END+'-2l')          # scroll to new end of text
                        textdisplay.config(state=DISABLED)  # '-2l' = before auto \n at end
                        textdisplay.update()                # run gui events now: else dead

                    except Exception as why:
                        print('Run Code shutdown:', why)    # stdout window was closed?
                        print('This may be normal if your output window was closed early')
                        line = '[EOF]'                      # exit timer loop, retain window?
                    # back to top of batch while loop

                if line == '[EOF]':
                    try:
                        textdisplay.focus_set()   # focus for scrolls, Escape
                    except:
                        pass   # ignore if window was closed: reported above
                else:
                    # reschedule and wait: check queue 10 times per second (msecs)
                    myargs = (linequeue, EOF, textdisplay, inputline, inputsend)
                    textdisplay.after(100, streamconsumer, *myargs)  # no-op if closed


            def onSendinput():
                """
                -------------------------------------------------------------
                Provide stdin in a user-activated field (e.g., on prompts).
                This may seem a bit clumsy, but it's simple and adequate.
                Stdin stream is now binary too: encode to bytes before send.
                -------------------------------------------------------------
                """
                inputtext = inputline.get()                    # yes, it's in scope
                inputtext = inputtext.encode(StreamEncoding)   # to subproc's encoding
                linesep   = os.linesep.encode(StreamEncoding)  # to b'\n' or b'\r\n'
                subproc.stdin.write(inputtext + linesep)       # flush() is required
                subproc.stdin.flush()                          # (in text-mode only?)


            def onCloseWindow():
                """
                -------------------------------------------------------------
                If user closes window while subproc still running, forcibly 
                kill the subproc so we don't leave a hung process waiting for
                input or stuck in a loop.  Allows user to kill the latter.
                A closure: most names here are per-run enclosing-scope state.

                We could just subproc.stdin.close() but that won't stop a
                spawned output-only or no-output program.  subprocess reaps
                zombies on del, but also force the issue here/now.  See above
                for the Windows hack here, the launch code below for more on 
                the Linux process group fix, and subprocproxy.py for more on
                subprocTempdir prune here.  TBD: should this verify kills?

                Now also run for still-open windows on PyEdit quit(), or else
                running spawnees die badly on SIGPIPE errors if they do any
                stream input or output.  No portable fix exists.  <Destroy>
                is not fired when quit(): use a class-global closure list.
                Run Code is disabled if PyEdit embedded: importers ignore.
                -------------------------------------------------------------
                """
                # kill program if still running
                if subproc.poll() == None:               # in scope: this Run
                    # still running
                    try:

                        if RunningOnWindows:
                            #
                            # subproc.kill() won't handle all cases here:
                            # run a tree+force taskkill for shell+children;
                            # force /f is required, but skips norm shutdown; 
                            # running the taskkill with os.system() pops up a
                            # console for frozen PyEdits, but Popen(shell=True)
                            # never does; Windows process groups didn't work;
                            #
                            killer = 'taskkill /pid %d /t /f' % subproc.pid
                            subprocess.Popen(killer, shell=True)

                        elif RunningOnLinux:    # including Android [4.0]
                            #
                            # send kill signal manually to all in the process
                            # group formed when the shell process was started;
                            # see the launch code ahead for more on this fix;
                            #
                            import signal
                            os.killpg(os.getpgid(subproc.pid), signal.SIGTERM)
                        
                        elif RunningOnMacOS:
                            #
                            # simple unix case: kill the proxy cmd, not the shell;
                            # stops proxy now, in any state: looping, paused, etc.
                            #
                            subproc.kill()
                            
                    except Exception as why:
                        print('Process kill exception', why)

                # reap zombies on window close
                if subproc.poll() != None:
                    subproc.wait(timeout=1)

                # prune frozen proxy temp dir if used and lingers
                if (subprocTempdir and                    # in scope: this Run
                    os.path.exists(subprocTempdir)):
                    try:
                        shutil.rmtree(subprocTempdir)
                    except Exception as why:
                        #showinfo('exc', str(why))
                        print('\t\tCannot prune %s [%s]' % (subprocTempdir, why))

                # close run window, whether spawnee exited normally or was killed
                stdoutwindow.destroy()

                # don't test on PyEdit quit: remove this run's function/closure
                TextEditor.openprograms.remove(onCloseWindow)


            def fixPyInstallerTkEnvVars(userpython):
                """
                -------------------------------------------------------------
                When PyEdit (not the subproc proxy) is run as a PyInstaller
                frozen executable on Windows or Linux, its TCL/TK_LIBRARY env
                variables get set by a PyInstaller runtime hook.  Back these
                out here from the environ passed to the subproc when using a
                user-configured Python, else Tcl/Tk will load versions from
                PyEdit's temp folder, not those in the user's chosen Python.
                It's too late to address these once the proxy is launched.
                For a GUI spawnee, these may be set anew by the host Python.

                [4.0] This also is run for the new macOS PyInstaller app, 
                where sys.frozen is True instead of the py2app string here.
                -------------------------------------------------------------
                """
                if (userpython != None and          # user-configured Python
                    hasattr(sys, 'frozen') and      # a frozen PyEdit running
                    sys.frozen != 'macosx_app'):    # but not a Mac py2app bindle
                    
                    # always fix so tk is loaded from user's python
                    # but iff a PyInstaller dir: user might set too;
                    # proxy is being run as source, not frozen exe;
                    # example setting value: "...\Temp\_MEI27802\tk";
                    
                    copyenv = os.environ.copy()
                    for key in ('TCL_LIBRARY', 'TK_LIBRARY'):
                        if key in os.environ:
                            if (os.sep + '_MEI') in os.environ[key]:
                                del copyenv[key]
                    return copyenv
                else:
                    # no harm in keeping vars (if set) in any other cases;
                    # proxy may be run as source (including app) or frozen exe
                    return os.environ
                    

            #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
            # Capture mode logic (sets enclosing-scope state used in closures above)
            #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++


            #-------------------------------------------------------------------
            # __BUILD__a new non-modal window for run's console streams.
            #
            # this window displays stdout+stderr output and provides stdin 
            # input; it's per-run and not automatically closed by new runs,
            # though closing it will automatically kill still-running code;
            #
            # [4.0] Windows (only) subprocproxy shows a splascreen on start,
            # and briefly on newer PCs; close it in frozen subprocproxy (only).
            #
            # [4.0] For macOS and Windows frozen subprocproxy, popup a one-
            # time info box suggesting a local-Python config for Run Code 
            # (and users who never read the docs...).  
            #
            # Nit: this one-time popop uses a sentinel file created in the 
            # program install folder.  This is known to work for source, macOS 
            # app in Applications, and Windows exe in Downloads, though it's 
            # a bit iffy for app and exe: some macOS might make app folders 
            # readonly, and Windows C:\Program Files* do too.  Using the user
            # home folder may be better, though this does not go away with 
            # app uninstall (delete).  This is a broader issue - the default
            # autosave folder is also in the install folder; here, a write 
            # fail means users nags on each Run Code until the config is set.
            #
            # [4.0] TBD - should there be a frozen subprocprocy at all?  
            # Requiring the config is less convoluted, but the frozen proxy 
            # may suffice for simple Python code and aid users intimidated 
            # by configs.  The proxy source-code script is still needed for 
            # launching code in non-frozen-proxy contexts (config, source).
            #-------------------------------------------------------------------
            
            # [4.0] nag user about config setting, just once
            nagpath = os.path.join(INSTALLDIR, '.runCodeNag')    # perm dir, hidden file
            if noPythonExe:                                      # frozen subproc proxy?
                if not os.path.exists(nagpath):                  # not yet nagged once?
                    my_showinfo(popup, 'Run Code',               # popup=onDoRunCode arg
                        'In apps and exes, Run Code supports a limited number '
                        'of modules by default.\n\n'
                        'For more modules and best Run Code results, please configure '
                        'a locally installed Python.\n\n'
                        'To do so, modify setting RunCode_PYTHONEXECUTABLE in your '
                        'textConfig.py file.\n\n'
                        'That file includes examples and resides in your unzipped PyEdit '
                        'folder on Windows and PyEdit.app/Contents/Resources on macOS.\n\n'
                        'This is a one-time setting which also speeds Run Code starts.')
                    self.update_idletasks()
                    try:
                        nagfile = open(nagpath, 'w')        # don't nag on later Run Code
                        nagfile.close()
                    except: 
                        pass                                # fail: nag until config set...
            
            stdoutwindow = Toplevel(self)                   # child of self: closes
            if noPythonExe:                                 # frozen subproc proxy?
                stdoutwindow.withdraw()                     # hide till get line #1
                self.text.update()                          # and unpress Run button

            try_set_window_icon(stdoutwindow)               # icons where supported
            stdoutwindow.title('PyEdit - Run Code: Streams')
            fixAppleMenuBarChild(stdoutwindow)              # dialog menubar fixer

            # [4.0] on Android, limit size but don't expand
            self.limitWindowToScreen(stdoutwindow)

            # stdin input line entry
            inputfrm = Frame(stdoutwindow)
            inputfrm.pack(side=TOP, fill=X)
            Label(inputfrm, text='Input Line?', relief=RIDGE).pack(side=LEFT)
            inputline = Entry(inputfrm)
            inputline.pack(side=LEFT, expand=YES, fill=X)
            
            inputsend = Button(inputfrm, text='Send', command=onSendinput)
            inputsend.pack(side=RIGHT, fill=Y)
            inputline.bind('<Return>', lambda event: onSendinput())

            # double-scrolled stdout+stderr display 
            area = Frame(stdoutwindow)                   # or a PyEdit component?
            vbar = Scrollbar(area)
            hbar = Scrollbar(area, orient='horizontal')
            text = Text(area, wrap='none')               # disable line wrapping
            text.config(undo=1, autoseparators=1)        # 2.0, default is 0, 1

            # pack last=clip first (clip sbars last)
            area.pack(expand=YES,  fill=BOTH)
            vbar.pack(side=RIGHT,  fill=Y)
            hbar.pack(side=BOTTOM, fill=X)
            text.pack(side=TOP,    fill=BOTH, expand=YES)

            # cross-link sbars and text
            text.config(yscrollcommand=vbar.set)     # call vbar.set on text move
            text.config(xscrollcommand=hbar.set)
            vbar.config(command=text.yview)          # call text.yview on scroll move
            hbar.config(command=text.xview)          # or hbar['command']=text.xview
            textdisplay = text                       # prior name, used from here on 

            # config style and clicks: select text, wrapping toggle
            textdisplay.config(relief=RIDGE, border=3)
            textdisplay.config(width=100)                   # chars, else dflt=80
            
            textdisplay.config(state=DISABLED)              # read/copy-only text
            textdisplay.bind('<Button-1>', 
                  lambda event: textdisplay.focus_set())    # click to copy on Mac

            textwrapped = False
            def toggleLineWrapping():
                nonlocal textwrapped                        # uses scope (not self)
                if not textwrapped:
                    textdisplay.config(wrap='char')         # no 'word' boundaries
                else:
                    textdisplay.config(wrap='none')         # turn wrapping back off
                textwrapped = not textwrapped
            textdisplay.bind('<Escape>',                    # <KeyPress-w><Button-1>?
                  lambda event: toggleLineWrapping())       # use same as edit window

            # config output font if set                     # default reasonable
            if Configs.get('runcodefont'):
                textdisplay.config(font=Configs['runcodefont'])

            # config output colors if set                   # default b/w suffices
            if Configs.get('runcodebg'):                    # uncolored may be best
                textdisplay.config(bg=Configs['runcodebg'])
            if Configs.get('runcodefg'):
                textdisplay.config(fg=Configs['runcodefg'])


            #-------------------------------------------------------------------
            # __LAUNCH__ the proxy to launch the edited program.
            #
            # pass cmdargs as entered: user must quote/escape as needed;
            # manually quote items we add to str (only seqs auto-quote);
            # proxy (app or exe) has all python standard libs baked in;
            #
            # for output streams, use binary mode + manual decode here,
            # and force prints in the spawnee to encode per UTF8 Unicode;
            # that supports non-ascii text, and avoids read decode errors;
            # also replace any non-BMP characters received for the GUI;
            #-------------------------------------------------------------------
            
            extras = {}
            if noPythonExe:
                # frozen proxy: run frozen exe directly (but not for Mac py2app);
                # python '-u' not available; no userpython or shipped py exe;
                # PyEdit is not an embedded dir here: proxy will be in '.'
                # with PyEdit exe unless PyEdit run from cmd line elsewhere;
                # stdout+stderr stream should be binary-mode, UTF8 Unicode,
                # and unbuffered, but it's not - see subprocproxy workaround;
                
                proxy  = 'subprocproxy'       # omitting .exe okay on Windows
                mydir  = INSTALLDIR           # not via __file__ if frozen
                proxy  = os.path.join(mydir, proxy)
                proxy  = quoteCmdlineItem(proxy)
                cmdstr = proxy + ' ' + quotethefile + ' ' + cmdargs
                os.environ['PYTHONUNBUFFERED'] = 'True'   # -u equiv (iff env?)
                os.environ['PYTHONIOENCODING'] = StreamEncoding
                extras = dict(env=os.environ)

            else:
                # source proxy: run script's source file with python executable;
                # use python set in textConfig.py, else python running PyEdit;
                # this branch is used for source-code PyEdit (e.g., on Android),
                # frozen Mac py2app app, and when Python executable is set in 
                # textConfig.py for both source and frozen PyEdits: use a .py 
                # proxy; the proxy script is not in '.' if PyEdit is embedded;
                # stdout+stderr stream is binary-mode, UTF8 Unicode, unbuffered;

                proxy  = 'subprocproxy.py'
                mydir  = INSTALLDIR           # uses dir(__file__) here
                proxy  = os.path.join(mydir, proxy)
                proxy  = quoteCmdlineItem(proxy)
                cmdstr = ' '.join([quotepython, '-u', proxy, quotethefile])
                cmdstr = cmdstr + ' ' + cmdargs
                os.environ['PYTHONIOENCODING'] = StreamEncoding   # Unicode?
                extras = dict(env=fixPyInstallerTkEnvVars(os.environ))

                if RunningOnMacOS and hasattr(sys, 'frozen') and userpython:

                    # force py2app macOS app bundle to support user-configured
                    # Python executable paths; without this, these 2 env vars
                    # inherit bundle settings, and libs are always those of
                    # the bundle's Python, not the Python set in textConfig.py;
                    # the source-code package doesn't have this issue on Macs;
                    # this saves any user paths, though PYTHONPATH isn't loaded
                    # if PyEdit is started by clicks anyhow (use textConfig.py);
                    # [4.0] TBD: applies to the new PyInstaller macOS app too?

                    def debugpaths(debug=False):
                        if debug:
                            my_showinfo(self, 'Debugging',
                                os.environ.get('PYTHONPATH', 'X') + '\n\n' +
                                os.environ.get('PYTHONHOME', 'X'))

                    debugpaths()
                    if 'PYTHONPATH' in os.environ:
                        alldirs = os.environ['PYTHONPATH'].split(os.pathsep)
                        alldirs = [d for d in alldirs if d != mydir]
                        if not alldirs:
                            del os.environ['PYTHONPATH']   # empty fails
                        else:
                            os.environ['PYTHONPATH'] = os.pathsep.join(alldirs)
                    if 'PYTHONHOME' in os.environ:
                        del os.environ['PYTHONHOME']       # or .pop(key, None)
                    debugpaths()

            # on Linux, launches and kills require special handling here: 
            # frozen proxies need a './' in case '.' is not on PATH, and 
            # must form a process group so that the proxy is killed along
            # with its shell on later window close; without process groups,
            # the later os.kill() kills the shell, not its proxy cmd child;
            # we must use shell=True to finesse cmdline-args parsing issues;
 
            # other ideas: prefixing the cmd with 'exec ' replaces the shell 
            # with its child such that a later subproc.kill() kills the child,
            # but this works only for a source-code proxy, not when it's frozen;
            # Mac doesn't require './' (it runs the proxy as source) or process
            # groups (a subproc.kill() kills the child);  Windows happily runs
            # programs in '.', and uses a taskkill command instead of .kill();
            # Popen(start_new_session=True) runs setsid() auto in python3.2+:
            
            if RunningOnLinux:    # including Android [4.0]
                # frozen proxy in frozen pyedit's dir?
                if cmdstr.startswith('subprocproxy '): 
                    cmdstr = './' + cmdstr               # in case '.' not on path

                # create a process group for shell+cmd
                extras.update(preexec_fn=os.setsid)      # so os.kill() kills cmd

            # this needs to: use strings to avoid arg splits on Windows, quote
            # all args it adds to cmd strings (only sequences auto-quote args),
            # use shell=True to avoid spurious cmd prompts for frozen executables
            # on Windows, use shell=True for strings to pass args to the script
            # on Unix, and allow the script to be forcibly killed everywhere;
            # see note "Killing spawned scripts" above for more background;

            # all 3 streams use binary mode now: must encode for stdin too;
            # proxy now does cwd: formerly rundir = os.path.dirname(thefile);
            # neither shell=True nor env=os.environ export login env on Mac;
            # debug: my_showinfo(self, 'xxx', cmdstr)

            subproc = subprocess.Popen(
                  cmdstr,                       # not seq: pass args as given
                  shell=True,                   # avoid popup for win exe, etc.
                  universal_newlines=False,     # binary streams, manual decode/eoln
                  stdout=subprocess.PIPE,       # capture sub's stdout here
                  stdin=subprocess.PIPE,        # provide sub's stdin here
                  stderr=subprocess.STDOUT,     # route sub's stderr to its stdout
                  **extras)                     # any special-case kw args needed

            # read and save the subproc's temp folder name for prune on kill;
            # only when proxy is a PyInstaller exe: not Mac py2app or source;
            # caveat: this can gobble line1 of a non-py error message, which
            # we could force to output or queue, but this should not occur;

            if noPythonExe:
                subprocTempdir = subproc.stdout.readline()    # get line #1
                subprocTempdir = subprocTempdir.decode(StreamEncoding).rstrip()
                stdoutwindow.deiconify()    # show window now (else temp pause) 
                #print(f'Read proxy line1: {subprocTempdir}', 
                #      file=open('/Users/me/LOG.txt', 'a'), flush=True)
            else:
                subprocTempdir = None
                #print('Did not read proxy line1',
                #      file=open('/Users/me/LOG.txt', 'a'), flush=True)


            #-------------------------------------------------------------------
            # __MONITOR__ the spawnee: read and process code's streams.
            #
            # start reader thread + timer-based poller for subproc's stdout/err;
            # provide stdin text when the user interacts in respose to prompts;
            # kill a still-running subproc on run-window close, or PyEdit quit;
            #-------------------------------------------------------------------
            
            EOF = None                   # stream lines read will never be this
            linequeue = queue.Queue()    # infinite-size shared queue of objects

            stdoutwindow.protocol('WM_DELETE_WINDOW', onCloseWindow)
            TextEditor.openprograms.append(onCloseWindow)

            _thread.start_new_thread(streamreader, (subproc.stdout, linequeue, EOF))
            streamconsumer(linequeue, EOF, textdisplay, inputline, inputsend)

            # back to Tk event loop, with after() timer polling loop started

        # End Capture mode
    # End onDoRunCode




    ############################################################################
    # Help menu commands (just one for now)
    ############################################################################


    #@modalMenuAction - no more, but my popups are
    def onHelp(self):
        """
        ------------------------------------------------------------------
        display my help text in a simple info dialog;  this could
        popup HTML via py's webbrowser module, but that seems overkill
        for PyEdit's intuitive actions;  caveat: showinfo() formats the
        text better on some platforms than others (Linux seems worst);
        this becomes "About" under "Help" on Mac and Linux because of
        GuiMaker's logic (Help content follows complex rules on Macs);

        [3.0] This now pops up a custom dialog that allows users to pick
        either About--the original showinfo text box, or User Guide--the
        new HTML doc auto-opened in a web browser.  Ideally, these would
        be separate menu entries, but the dialog is the easiest way to
        work with GuiMaker's Help logic unchanged (any more, at least).

        [3.0] Now splits up the original help text into two halves: About
        and Versions.  The combo was too long for an info box on Linux
        (and small screens?), and info boxes can't be adjusted.  Versions
        is still not short, but what would you expect from PP4E's author?

        [3.0] Subtle: the About and Versions info boxes are children of 
        the Help dialog (not self TextEditor) so Help gets active focus 
        on close.  This makes it only partly modal on Mac, but acceptably.
 
        [4.0] Show About and Versions in a ScrolledText on Linux+Android,
        and scroll Versions everywhere; also tweaked help text itself.
        ------------------------------------------------------------------
        """

        # 
        # onHelp button handlers
        #

        def scrolledhelppanel(root, kind, helptext):
            """
            ----------------------------------------------------------------
            [4.0] Show info text in a scrolled Toplevel/Text within a new 
            non-model window, if the text may be too big for a Tk common 
            dialog on Linux (where the dialog is oddly narrow) or Android 
            (where the dialog is wider but truncates its text on the right). 

            This is a limited but simple workaround adapted from PyGadgets.
            Tk font size 0 here means default for family - except on Android!
            Had OK button in Android patch, but useless: 'X'=close on both.

            Update: now used on ALL platforms for long Versions text for 
            accessibility (e.g., large fonts or small displays), and use
            the same font as edit windows, not odd 'system 0 normal'.

            Nit: maybe use this for all longish dialog text on Android?
            But what is longish? - will vary by screen size (phone, tablet,
            foldable) and this feels as iffy as CSS media queries.  As a
            workaround, added \n\n line breaks in longer showinfo text.
            ----------------------------------------------------------------
            """
            from tkinter.scrolledtext import ScrolledText    # yes, should be top of file
            title = f'{root.appname} - {kind}'
            helptext = helptext.strip()                      # drop ' ' and \n, both sides
 
            win = Toplevel()
            win.title(title)
            try_set_window_icon(win)                    # icon on windows, linux

            fixAppleMenuBarChild(win)                   # macOS menu bar for Versions 
            self.limitWindowToScreen(win)               # [4.0] Android: limit, no expand

            text = ScrolledText(win, wrap='word')       # wrap on word boundaries
            """
            if RunningOnLinuxOnly:                      # not Android: 0=microscopic!
                text.config(font='system 0 normal')     # std fam/size, bold better?
            """
            text.config(font=Configs.get('font', 'system 12 normal')) 
            text.pack(expand=YES, fill=BOTH)
            text.insert(END, helptext)

        @modalMenuAction
        def onAbout():
            """
            ----------------------------------------------------------------
            display text in a modal popup
            original version help, half1 (force popup on Mac, not slide-down)
            ----------------------------------------------------------------
            """
            if RunningOnLinuxOnly or RunningOnAndroid:
                scrolledhelppanel(popup, 'About', HelpText_About)
            else:
                orphan = RunningOnMacOS
                my_showinfo(popup, 'About', HelpText_About, orphan=orphan)

        @modalMenuAction
        def onVersions():
            """
            ----------------------------------------------------------------
            display text in a modal popup  
            original version help, half2 (force popup on Mac, not slide-down)
            Update: scrolled everywhere - may be too big for some fonts/displays 
            ----------------------------------------------------------------
            """
            if True or RunningOnLinuxOnly or RunningOnAndroid:
                scrolledhelppanel(popup, 'Versions', HelpText_Versions)  
            else:
                orphan = RunningOnMacOS
                my_showinfo(popup, 'Versions', HelpText_Versions, orphan=orphan)
 
        def onReadme():
            """
            ----------------------------------------------------------------
            display text file in an independent PyEdit window
            don't close with help dialog: user may edit in this window,
            and closing with help would silently ignore unsaved changes;
            ----------------------------------------------------------------
            """
            # [4.0] __file__ may not be cwd after an os.chdir() on Android
            myreadme = os.path.join(mysourcedir, 'README.txt')
            if not os.path.exists(myreadme):
                myreadme = os.path.join(os.getcwd(), 'README.txt')

            TextEditorMainPopup(
                    parent=None,               # parent = None = Tk root:
                    loadFirst=myreadme,        # not auto-closed with Help popup 
                    winTitle=None,             # no label: a full edit window
                    loadEncode='UTF-8')        # has Unicode copyright

        def onUserGuide():
            """
            ----------------------------------------------------------------
            display html file in a web browser
            Android patches used an activity-mgr cmdline, but webbrowser works today
            ----------------------------------------------------------------
            """
            # [4.0] always use online version to allow for changes, Android access
            import webbrowser
            helpurl = 'https://learning-python.com/pyedit-products/unzipped/UserGuide.html'
            webbrowser.open(helpurl)

            """CUT
            myuserguide = os.path.join(mysourcedir, 'UserGuide.html')
            if os.path.exists(myuserguide):
                webbrowser.open('file:' + myuserguide)
            else:
                # could fail for same reason as image load below
                my_showinfo(self, 'User Guide',
                            'Sorry - cannot find user guide HTML file')
            CUT"""

        #
        # onHelp implementation
        #

        # get source dir from __file__, whether embedded or standalone;
        # update: using __file__ fails for source-code and Mac apps, but
        # sys.argv[0] scheme required for frozen PyInstaller executables;
        mysourcedir = INSTALLDIR

        # split help text into About + Versions: too long on Linux
        chop = HelpText.find('PyEdit Version History')
        HelpText_About, HelpText_Versions = HelpText[:chop].strip(), HelpText[chop:]

        # build a simple non-modal dialog
        popup = Toplevel(self, bg='white')   # close with parent? (tbd)
        try_set_window_icon(popup)           # Windows+Linux icon image
        fixAppleMenuBarChild(popup)          # Mac menubar fixer for dialogs
        self.limitWindowToScreen(popup)      # [4.0] Android: limit size, don't expand

        popup.title('PyEdit %s - Help' % Version)
        popup.appname = 'PyEdit'             # for callDialog (non-TextEditor)

        dlgfont = 'helvetica'
        tagline = ' PyEdit \u2014 Edit text. Run code. Have fun.'
        Label(popup, text=tagline, bg='white',
                     fg='black',               # [4.0] macOS nonsense (dark mode)
                     font=(dlgfont, 18, 'bold italic'), bd=15).pack()
        
        # display icon image: gif works on all py 3.Xs
        imgpath = os.path.join(mysourcedir, 'icons', 'pyedit-window-main.gif')
        try:
            gifimg = PhotoImage(file=imgpath)
            imglab = Label(popup, image=gifimg, bg='white')
            imglab.pack(expand=NO, side=LEFT)
            popup._save_pyedit_help_img = gifimg      # else erased if no more refs

        except Exception as why:
            # --the following is now moot, because String mode was withdrawn--
            # unlikely, but image load can fail if cwd is reset temporarily when
            # a paused input() [fixed] or GUI-code mainloop() [unfixable] restarts 
            # the GUI during the exec() in Run-Code's String mode (rare but true!)
            print('PyEdit image load failed:', why)   # continue without the image

        # help content/format buttons
        btnfont = (dlgfont, 14, 'bold')
        Button(popup, text='About',
               font=btnfont, bg='white', 
               highlightbackground='white',    # [4.0] macOS nonsense (borders)
               command=onAbout).pack(padx=10, pady=10)

        Button(popup, text='Versions',
               font=btnfont, bg='white', 
               highlightbackground='white',    # [4.0] macOS nonsense
               command=onVersions).pack(padx=10, pady=10)

        Button(popup, text='Readme',
               font=btnfont, bg='white',
               highlightbackground='white',    # [4.0] macOS nonsense
               command=onReadme).pack(padx=10, pady=10)

        Button(popup, text='User Guide',
               font=btnfont, bg='white',
               highlightbackground='white',    # [4.0] macOS nonsense
               command=onUserGuide).pack(padx=10, pady=10)

        Button(popup, text='Close Help',
               highlightbackground='white',    # [4.0] macOS nonsense
               command=popup.destroy).pack(padx=10, pady=10, side=BOTTOM)




    ############################################################################
    # Utilities, useful outside this class too
    ############################################################################


    # Access text content
    
    def isEmpty(self):
        return not self.getAllText()

    def getAllText(self):
        return self.text.get(START, END+'-1c')    # extract text as str string

    def setAllText(self, text):
        """
        ----------------------------------------------------------------
        Caller: call self.update() first if just packed, else the
        initial position may be at line 2, not line 1 (2.1; Tk bug?).

        [3.0] UPDATE: Yes, this is/was a Tk 8.5 bug, until at least
        late 2015: http://core.tcl.tk/tk/tktview/1739605.  The best 
        workaround is to either not call see() at all and assume that
        the view is at the top, or call Text.see() twice in succession
        as done here (see ../docetc's demo-tk-line1-scroll-bug.py for
        a minimal proof).  Later Tks may also help, but iff installed.
 
        Hence, callers *no longer must call update()* to fix the see()
        line #1 issue for just-packed PyEdit windows.  And they probably
        shouldn't - doing so can cause a visible flash even if windows
        withdraw() and deiconify() to hide during builds, and may even
        trigger an unrelated initial sizing bug in Tk 8.5 that ignores
        config() but is officially outside the scope of this docstring. 
        ----------------------------------------------------------------
        """
        if isinstance(text, str):                 # [3.0] sanitize to display
            text = fixTkBMP(text)                
        self.text.delete(START, END)              # store text string in widget
        self.text.insert(END, text)               # or START; text=bytes or str
        self.text.mark_set(INSERT, START)         # move insert point to top
        self.text.see(INSERT)                     # scroll to top, insert set
        self.text.see(INSERT)                     # no, really: see note above

    def clearAllText(self):
        self.text.delete(START, END)              # clear text in widget



    # Access filename and text's Unicode encoding
    
    def getFileName(self):
        return self.currfile
    
    def setFileName(self, name):                  # see also: onGoto(linenum)
        """
        [3.0] absolutize + normalize file's pathname
        for matches against the open-file list, etc.;
        this also drops odd '/' from GUI on Windows;
        """
        if name != None:                          # abspath() runs normpath()
            name = os.path.abspath(name)          # else mixed slashes on Win
        # for saves, already-open test, run-code
        self.currfile = name
        # [3.0] gui: sanitize Unicode text
        self.filelabel.config(text=fixTkBMP(str(name)))   # may be None

    def setKnownEncoding(self, encoding='utf-8'): # 2.1: for saves if inserted
        self.knownEncoding = encoding             # else saves use config, ask?



    # Change colors and font
    
    def setBg(self, color):
        self.text.config(bg=color)                # to set manually from code

    def setFg(self, color):                       # caveat: not used everywhere
        self.text.config(fg=color)                # 'black', '#RRGGBB' hexstring
        self.text.config(insertbackground=color)  # [3.0] cursor=fg, for dark bg

    def setFont(self, font):
        self.text.config(font=font)               # ('family', size, 'style')



    # Change window size
    
    def setHeight(self, lines):                   # default = 24h x 80w
        self.text.config(height=lines)            # may also be from textConfig.py

    def setWidth(self, chars):
         self.text.config(width=chars)



    # Access Tk's text-modified flag and undo stack
    
    def clearModified(self):
        self.text.edit_modified(0)                # clear modified flag

    def isModified(self):
        return self.text_edit_modified()          # changed since last reset?

    def clearUndoStack(self):
        self.text.edit_reset()                    # discard any changes made
        
    @staticmethod
    def anyWindowsModified():
        """
        [3.0] return list of open windows that have unsaved
        changes; this list is Boolean False if it is empty;
        it spans all PyEdit window types: pop-up or component;
        client programs may use this prior to an app quit,
        and may call it through the class name with no self;
        """
        return [w for w in TextEditor.openwindows if w.text_edit_modified()]



    # Forced scroll to top or bottom

    def seeTop(self):
        """
        [3.0] Tk still has a bug that opens with line 2 at the
        top for set text; only update() fixes this, not seeTop(),
        but update() unfortunately can also cause a brief flash.
        """
        self.text.see(START)           # scroll to line 1, column 0
        self.text.see(START)           # if just packed: see setAllText
       #self.text.yview_moveto(0.0)    # alternative, but not see() fix


    def seeEnd(self):
        self.text.see(END)             # scroll to end of current text
        self.text.see(END)             # if just packed: see setAllText
       #self.text.yview_moveto(1.0)    # alternative, but not see() fix




################################################################################
# Ready-to-use, top-level editor classes
# Each mixes in a GuiMaker Frame subclass which builds menu and toolbars.
#
# These classes are common use cases, but other configurations are possible.
# Call TextEditorMain().mainloop() to start PyEdit as a standalone program.
# Redefine/extend onQuit in a subclass to catch exit or destroy (see PyView).
# Caveat: could use windows.py for icons, but quit protocol is custom here.
################################################################################


"""
--------------------------------------------------------------------------------

2.1: Quit protocol notes

On quit(), no longer silently exits entire app if any other changed edit
windows are open in the process - changes would be lost because all other 
windows are closed too, including multiple Tk editor parents.  Uses a list
to keep track of all PyEdit window instances open in process.  This may be 
too broad (if we destroy() instead of quit(), need only check children
of parent being destroyed), but better to err on side of being too inclusive.
onQuit moved here because varies per window type and is not present for all.

Assumes a TextEditorMainPopup is never a parent to other editor windows -
Toplevel children are destroyed with their parents.  This does not address 
closes outside the scope of PyEdit classes here (tkinter quit is available 
on every widget, and any widget type may be a Toplevel parent!); clients are
responsible for checking for editor content changes in all uncovered cases.
Note that tkinter's <Destroy> bind event won't help for change testing here,
because its callback cannot run GUI operations such as text change tests and
fetches - see the book and destroyer.py for more details on this event.
<Destroy> can still be used for non-GUI chores (e.g., 3.0 list removals).

--------------------------------------------------------------------------------

[3.0] Top-level class updates and notes

PyEdit now tracks every open window - both top-level and component - for
both change tests and auto-saves.  Windows are added to the open-windows
list in __init__, and removed in their Text widget's <Destroy> handler.
This extends 2.1's top-level window change testing on Quit: both 2.1's change
testing and 3.0's auto-saves now apply to every PyEdit instance in a process.

For example, every standalone PyEdit popup window, as well as each PyMailGUI
popup or View-window component is auto-saved (subject to config file settings),
and can be checked for changes on program exits.  Pyedit's standalone root
window automatically checks for changes, and methods are provided for component
clients like PyMailGUI to check for changes as desired.

BUT PARENTAGE STILL MATTERS IN TK: despite this generalization, it's important
to remember that when a widget is destroyed, all its child widgets are also
silently destroyed with it.  Automatic window closure is sometimes a feature,
but it also can be a major flaw if a user's changes are lost in the process.
PyEdit's window tracking is automatic, but its <Destroy> handler cannot detect
or handle unsaved changes.  Hence, PyEdit clients should still generally:

1) Use the program's main Tk() root that endures for the entire program
   as the parent of all popups.  This ensures that popups are not silently
   closed without trigerring their onQuit protocols.  This root parent is
   automatically used if no explicit parent is passed when creating popups.

2) Call a PyEdit component window's change-testing methods manually when a
   parent window is about to be destroyed, to prompt and allow for user saves.
   
PyEdit standalone mode satisfies the rules simply, by passing no parent
when creating popup windows - which makes their parent the main Tk() root.

As a client example, in PyMailGUI:
-- Transient View windows cannot be parents to PyEdit text-part or raw text
   popups; else the popups may be silently closed with View windows.
-- View windows with embedded PyEdit text cannot themselves have transient
   parents, such as saved-mail List windows; else a View window's text
   component may be silently closed along with the View.
-- View windows being closed by the user should call the isModified() method
   here to give users a chance to save changed text in nested components.
-- The top-level root (server List window ) should call anyWindowsModified()
   here to give users a change to save any nested component before exit.
   
In sum, silent parentage-based closure is not recommended.  A PyEdit window,
whether top-level or embedded, should never be silently closed from a usage
perspective: users should always be given a chance to save changes.  When
in doubt, use the implicit root window as parent to avoid silent closes.
See also the 3.0 dialog patches above in this file: parentage maters for
dialogs too, as it determines window lifts, and dialog style on Mac OS X.  

Mac update: the app-menu and Dock Quit is now equivalent to the main-window
Quit - it verifies unsaved changes any, and ends the program.  Popup window
toolbar and File-menu Quit still apply to and close the source window only.

General update: the main-window class now also kills any still-running 
Run-Code spawnees, to avoid SIGPIPE errors for run programs that do any 
stream output or input.  This happens only when PyEdit is run standlone,
but that's the only time Run Code is enabled.  See the class's onQuit().

[4.0] onQuit need test for in-process mods only: it will close just windows 
in this process, not others.  onQuit, however, does need to delete lockfiles
for a single or all windows (though would be cleaned-up zombies else). 
--------------------------------------------------------------------------------
"""



#*******************************************************************************
# When text editor owns the window: main
#*******************************************************************************

class TextEditorMain(TextEditor, GuiMakerWindowMenu):
    """
    ----------------------------------------------------------------------------
    Main PyEdit top-level windows that quit() to exit entire app on a Quit
    in GUI, build a menu on a window, and check for changes in all other
    top-level windows on close.  Generally used for PyEdit's main window.
    onQuit is run for Quit in toolbar or File menu, as well as window border X,
    and will also be called from application menu and Dock Quit on Mac OS X.

    Builds on a passed-in parent, which must be a window - a Tk (explicit, or
    default=None) or Toplevel - and probably should be a Tk so the window isn't
    silently destroyed and closed with a transient parent.  All non-popup main
    PyEdit windows check all other PyEdit windows open in the process for changes
    on a Quit in the GUI, since a quit() here will exit the entire app.  Editor
    Frame need not occupy entire window (see PyView), but its Quit ends program.

    Tk roots have no parent themselves - they are parent to widgets built here,
    though a Clone of this window creates a Toplevel to serve as its container.
    UPDATE: Quits also kill any still-running Run-Code spawnees: see onQuit(). 
    ----------------------------------------------------------------------------
    """
    
    def __init__(self, parent=None, loadFirst='', loadEncode=''):
        """
        --------------------------------------------------------------------
        editor fills entire parent window
        --------------------------------------------------------------------
        """
        GuiMaker.__init__(self, parent, _fullTkVersion)   # use main window menus [4.0]+tk
        
        try_set_window_icon(self.master)                  # [3.0] set (some) icons
        wintype   = ' ✍' #if RunningOnMacOS else ''       # [3.0] distinguish (or ✐)
        wintype   = ''                                    # [4.0] drop hieroglyphics
        fulltitle = 'PyEdit %s - Main' + wintype          # use diff icon on Win/Lin

        self.master.title(fulltitle % Version)            # title on parent win
        self.master.iconname('PyEdit')                    # tkinter sets .master!

        # set wm X or red-dot close callback if full window
        self.master.protocol('WM_DELETE_WINDOW', self.onQuit)

        # [3.0] do this _after_ borders: may trigger unicode popup 
        TextEditor.__init__(self, loadFirst, loadEncode)  # GuiMaker frame packs self

        # [3.0] +track for change-test and auto-save in __init__ and <Destroy>
        

    @modalMenuAction
    def onQuit(self):
        """
        --------------------------------------------------------------------
        on Quit in GUI (menu, tooolbar, WM x, macOS): quit app
        quit() ends the entire program regardless of widget type
        there's no need to clear tracking lists here: exiting
        
        [3.0] on Mac this may also be triggered from app-menu
        or Dock when any window may be on top: rewritten to not
        treat self specially when asking about checking changes;

        [3.0] run Run-Code closures to kill any still-running spawnees
        so they don't die badly later on output or input pipe errors;
        for all still-open run windows: a no-op if spawnee not running;
        no need to close programs when embedded: Run Code is disabled;
        caveat: this could warn the user and ask, but it's documented;
 
        [4.0] on macOS, add a verification when no changes in >1 open
        window; closing ALL open windows with main window is too abrupt.

        [4.0] delete lockfiles for non-None files open in any window.
        A psutil install now required for process-list test and info
        of cross-process aready-open test and zombie lockfile detection.
        This test could subsume some of the in-process test for single-
        instance usage on macOS, but in-process retained for window raise. 
        --------------------------------------------------------------------
        """
        doquit = False

        # check all windows for unsaved changes
        allwins = TextEditor.openwindows
        changed = [w for w in allwins if w.text_edit_modified()]
        if not changed:
            # none changed: close silently
            # [4.0] doquit = True

            # [4.0] verify with user if>1 window: closing all silently is abrupt!
            if len(allwins) == 1:
                doquit = True            # just unchanged main: close it
            else:
                verify = ('This will close all open (and unchanged) PyEdit windows.\n\n'
                          'Proceed with the closes?')
                doquit = my_askyesno(self, 'Quit', verify)

        else:
            if len(allwins) == 1:
                # just me open: specialize the message
                verify = ("This window's text is changed and unsaved.\n\n"
                          'Quit and discard its changes?')
            else:
                # [3.0] ask about all, new message format
                numchange = len(changed)
                verify = ('%s window%s ha%s unsaved changes.\n\n'
                          'Quit and discard %s changes?')                     
                verify %= ((numchange,) +
                       [('', 's', 'its'), ('s', 've', 'all')][numchange > 1]) 

            if my_askyesno(self, 'Quit', verify):
                # quit without saving (but auto-saves remain)
                doquit = True
            else:
                # [3.0] lift changed windows for convenience
                self.liftWindows(changed)

        if doquit:
            # [3.0] run Run-Code closures to kill any still-running spawnees

            for onCloseWindow in TextEditor.openprograms.copy():
                onCloseWindow()   # runs a closure, changes list in-place

            # [4.0] remove lockfiles for all windows with an open file  
            # we don't care about other windows having the file open here
    
            for window in allwins:
                window.delete_openfile_lockfile()   # window's file (None ok), self pid

            # and close all PyEdit windows, without triggerring <Destroys>s
            GuiMaker.quit(self)



#*******************************************************************************
# When text editor owns the window: popup
#*******************************************************************************

class TextEditorMainPopup(TextEditor, GuiMakerWindowMenu):
    """
    ----------------------------------------------------------------------------
    Popup PyEdit top-level windows that destroy() to close only self on a Quit
    in the GUI, close with their parent (usually the app root), build a menu on
    a window, and do not check for changes in any other windows on close.
    onQuit is run for Quit in toolbar or File menu, as well as window border X,
    but not from application-menu or Dock Quit when run on Mac OS X.
    
    Makes and builds on new Toplevel window, which is itself a child to another
    parent - the root Tk (for None), an explicit Tk, or other passed-in window
    or widget.  Adds to edit-windows list so will be checked for changes if any
    PyEdit main window quits, and included in auto-saves.

    The new window's parent should generally be the program's Tk root (e.g., a
    main PyEdit window's parent - which is automatic if parent is None), so it
    won't be silently closed by a transient parent's closure while being tracked
    for changes or auto-saves.  This won't cause errors (<Destroy> events now
    update tracking lists), but any unsaved changes would be ignored on close.
    This is bad enough that a "note" is issued here if parent isn't a Tk; this
    is okay iff the client program has its own change tests (e.g., PyMailGUI),
    but not otherwse (e.g., Help initially made README popups dialog chldren).

    [3.0] Note: client programs run on Mac OS X that create TextEditorMainPopup
    windows but are not themselves GuiMakerWindowMenu clients should also call
    guimaker.fixAppleMenuBar() with their app root window's help and quit info.
    That function saves and reapplies the app's info to PyEdit popups, so that
    its application menu's help and quit apply to the whole app as usual.
    ----------------------------------------------------------------------------
    """
    
    def __init__(self, parent=None, loadFirst='', winTitle='', loadEncode=''):
        """
        --------------------------------------------------------------------
        create and fill own popup editor window
        --------------------------------------------------------------------
        """            
        self.popup = Toplevel(parent)                         # None: parent=Tk root
        GuiMaker.__init__(self, self.popup, _fullTkVersion)   # use main window menus [4.0]+tk
        assert self.master == self.popup                      # tkinter sets .master!

        try_set_window_icon(self.popup, kind='-popup')        # [3.0] set (some) icons
        winTitle  = winTitle or 'Popup'                       # [3.0] '' if popup Clone
        wintype   = ' ☝' #if RunningOnMacOS else ''           # [3.0] distinguish (or ⚐, ⇧)
        wintype   = ''                                        # [4.0] drop hieroglyphics
        fulltitle = 'PyEdit %s - %s' + wintype                # use diff icon on Win/Lin

        self.popup.title(fulltitle % (Version, winTitle))
        self.popup.iconname('PyEdit')
        self.popup.protocol('WM_DELETE_WINDOW', self.onQuit)

        # [3.0] do this _after_ borders: may trigger unicode popup
        TextEditor.__init__(self, loadFirst, loadEncode)  # a frame in a new popup

        # [3.0] should tracking be selectable by args? (tbd)
        if not isinstance(self.popup.master, Tk):
            print("PyEdit note: tracked window's parent is not Tk")
            
        # [3.0] +track for change-test and auto-save in __init__ and <Destroy>


    @modalMenuAction
    def onQuit(self):
        """
        --------------------------------------------------------------------
        on Quit request in GUI: destroy this window only
        [3.0] called for window's menu or toolbar Quit or WM x (only)
        --------------------------------------------------------------------
        """
        # check this window's unsaved changes only
        close = not self.text_edit_modified()
        if not close:
            close = my_askyesno(self, 'Quit',
                             "This window's text is changed and unsaved.\n\n"
                             'Quit and discard its changes?')
        if close:

            # [4.0] remove lockfile for this window only, if file != None  
            # we DO care here if other windows in process have open: skip

            if self.is_last_open_in_process():
                self.delete_openfile_lockfile()   # window's file, self pid

            # close this window only (plus its child widgets/windows)
            # <Destroy> removes self from openwindows list

            self.popup.destroy()


    def onClone(self):
        TextEditor.onClone(self, makewindow=False)    # I make my own pop-up!



#*******************************************************************************
# When editor embedded in another window: with File/Quit
#*******************************************************************************

class TextEditorComponent(TextEditor, GuiMakerFrameMenu):
    """
    ------------------------------------------------------------------------
    Attached PyEdit component frames with full menu/toolbar options,
    which run a destroy() on a Quit in the GUI to erase self only.
    A Quit in the GUI verifies if any changes in self (only) here.
    Does not intercept window manager border X: doesn't own window.
    TBD: decorate borders if parent is a Tk or Toplevel (e.g., Clone)?

    [3.0] Allow components to be change-tested and auto-saved: add
    self to the openwindows list managed by __init__ and <Destroy>;

    [3.0] Clients: use TextEditor.anyWindowsModified() to check for
    changes in any window on app quit, and instance.isModified()
    to check for changes in a single window on container window close;
    clients can also run the onSave() method directly as desired;

    [4.0] Clients are also responsible for delete_openfile_lockfile()
    for the frame and any Popup windows it spawns; otherwise, any 
    lockfiles made by component will be cleanup up as zombies later;
    isn't done here because we don't know if this is a root or not;
    ------------------------------------------------------------------------
    """
    
    def __init__(self, parent=None, loadFirst='', loadEncode=''):     
        """
        --------------------------------------------------------------------
        embedded, Frame-based menus
        --------------------------------------------------------------------
        """
        GuiMaker.__init__(self, parent, _fullTkVersion)    # all menus, buttons, [4.0]+tk
        TextEditor.__init__(self, loadFirst, loadEncode)   # GuiMaker must init 1st

        # [3.0] +track for change-test and auto-save in __init__ and <Destroy>


    @modalMenuAction
    def onQuit(self):
        """
        --------------------------------------------------------------------
        on Quit request in GUI: destroy Frame
        --------------------------------------------------------------------
        """
        # check this component's unsaved changes only
        close = not self.text_edit_modified()
        if not close:
            close = my_askyesno(self, 'Quit',
                             'Text is changed and unsaved.\n\n'
                             'Quit and discard its changes?')
        if close:
            # erase self Frame but do not quit enclosing app
            # <Destroy> removes self from openwindows list
            self.destroy()



#*******************************************************************************
# When editor embedded in another window: without File/Quit
#*******************************************************************************

class TextEditorComponentMinimal(TextEditor, GuiMakerFrameMenu):
    """
    ------------------------------------------------------------------------
    Attached PyEdit component Frames without Quit and File menu options.
    On startup, removes Quit from toolbar, and either deletes File menu 
    or disables all its items (at the cost of maintenance work); menu and
    toolbar structures are per-instance data: changes do not impact others.
    
    Quit in GUI never occurs, because it is removed from available options;
    instead, a <Destroy> event is used to deregister from tracking lists,
    and clients should xall a change-test method on container and app quit. 
    TBD: decorate borders if parent is a Tk or Toplevel (e.g., Clone)?
    
    [3.0] Allow components to be change-tested and auto-saved: add
    self to the openwindows list managed by __init__ and <Destroy>;
    see ahead for change-test methods available.

    [3.0] Uses client method call to prompt for save if text changed.
    Note that these windows are tracked for changes on PyEdit root 
    window quits, but are part of other windows when used in another
    program with its own root - clients call change-testing manually.

    [4.0] Clients are also responsible for delete_openfile_lockfile()
    for the frame and any Popup windows it spawns; see prior class;
    ------------------------------------------------------------------------
    """
    
    def __init__(self, parent=None, loadFirst='', deleteFile=True, loadEncode=''):
        """
        --------------------------------------------------------------------
        embedded, Frame-based menus, no File/Quit
        --------------------------------------------------------------------
        """
        self.deleteFile = deleteFile
        GuiMaker.__init__(self, parent, _fullTkVersion)    # GuiMaker Frame packs self, [4.0]+tk
        TextEditor.__init__(self, loadFirst, loadEncode)   # TextEditor adds middle

        # [3.0] +track for change-test and auto-save in __init__ and <Destroy>


    def checkForLastChanceSavePrompt(self):
        """
        --------------------------------------------------------------------
        [3.0] optionally called at container's close to prompt for save
        if component text has been changed and not saved to a file;
        we can't veto the close here - this is just a chance to save;

        OTHER OPTIONS:
        -- On container window close, call instance.isModified()
           to check for changes in a single window, and cancel close 
        -- On enclosing app quit, call TextEditor.anyWindowsModified()
           to check for changes in any PyEdit window, and cancel quit
        -- Clients can also run instance.onSave() directly as desired
           to prompt the user for a save
        
        clients should ensure that component will not be closed without
        some change-testing (e.g., use the Tk root for its container's
        parent, not a transient window); <Destroy> cannot check or fetch;  
        --------------------------------------------------------------------
        """
        if self.text_edit_modified():
            if my_askyesno(self, 'Component close',
                        'Text changed: save its changes now?'):
                self.onSave()   # an automatic Save button press

        
    def start(self):
        """
        --------------------------------------------------------------------
        extend start() setup method to remove Quit/File
        --------------------------------------------------------------------
        """
        TextEditor.start(self)                         # GuiMaker start call
        for i in range(len(self.toolBar)):             # delete quit in toolbar
            if self.toolBar[i][0] == 'Quit':           # delete file menu items,
                del self.toolBar[i]                    # or just disable file
                break
        if self.deleteFile:
            for i in range(len(self.menuBar)):
                if self.menuBar[i][0] == 'File':
                    del self.menuBar[i]
                    break
        else:
            for (name, key, items) in self.menuBar:
                if name == 'File':                     # CAUTION: this may break
                    items.append([1,2,4,5,6])          # if file menu is changed




################################################################################
# standalone program run
################################################################################


def testPopup():
    # see also PyView and PyMailGUI for component tests
    root = Tk()
    TextEditorMainPopup(root)
    TextEditorMainPopup(root)
    Button(root, text='More', command=TextEditorMainPopup).pack(fill=X)
    Button(root, text='Quit', command=root.quit).pack(fill=X)
    root.mainloop()


def main():
    """
    --------------------------------------------------------------------------
    Standalone launch: may be typed or clicked, and associated with files.
    No need for heroics to set Mac active-window style here: it's all menus.

    [3.0] Magic no more: this formerly used the implicit/automatic Tk() root,
    because 'parent' defaulted to None, which triggered a default Tk() in
    GuiMaker.  That seems too implicit (especially given that parentage is
    crucial to window closures), so changed to make the root obvious here.
    Because popups pass no explicit parent, root here will be parent to all.

    [3.0] For Mac py2app app-bundle distribution only, manually catch the
    OpenDocument apple event.  This event is delivered both when an associated
    text file is clicked, and when a file is dropped onto the app's icon.  The
    file's name would normally become a command-line arg processed as usual
    (and does for Windows exes created by PyInstaller,) but py2app's argv
    emulation is currently broken (the workaround here dates back to 2012),
    and the event is better: supports drag-and-drop, Open With, and clicks.

    [4.0] Code here implements macOS single-instance mode, which sends a 
    message to the running process instead of spawning a new one.  This is
    nice when it applies and supports in-process already-open tests; but it's
    possible to spawn a new instance on macOS from command lines, and no such
    facility is readily usable on other hosts.  Hence, 4.0 augments this with
    its cross-process already-open test for all platforms; see docs above.

    [4.0] Recoded instance-start logic to manually onOpen() instead of passing
    loadFirst, so that a file already open in another process causes the window 
    and instance to be auto-closed if the user opts to not reopen.  Formerly, 
    left the window open and blank, which differed from auto-close behavior 
    for files already open in the SAME process.  Largely moot for macOS UI, 
    but _every_ explorer click is a new intance (i.e., process) elsewhere.
    --------------------------------------------------------------------------
    """
    import time
    
    try:                                              
        fname = sys.argv[1]                           # arg = optional filename
    except IndexError:                                # Mac app uses doc events 
        fname = None

    if RunningOnMacOS and fname and fname.startswith('-psn_'):
        #------------------------------------------------------------------
        # [3.0] on Mac, ignore a ProcessSerialNumber in argv that _may_
        # be passed when a file is opened by Finder via Launch Services;
        # apps cannot use argv in this context, but must instead respond
        # to Mac OpenDoc events on clicks, drops, and Open-Withs -- see
        # the special handler code below; we still allow a valid argv
        # filename, as pyedit might be run from a command-line too;
        #
        # without this check, pyedit would on rare occasion fail to launch
        # for specific files _only_, when the app was not yet running _only_,
        # due to argv[1] == '-psn_0_3834792' (e.g.) causing onOpen() errors
        # and hung error popups;  offending files opened in pyedit otherwise, 
        # copy or rename didn't help, and a TextEdit Save removed the issue;
        #
        # this seemed to happen for files opened in MS-Word inadvertently, 
        # and possibly after a system restart; for one file that triggered 
        # the bogus arg, MS-Word left behind Mac extended attributes...
        #
        # $ ls -l@ Whitepaper.html 
        # -rwxrwxrwx@ 1 blue  wheel  90072 May 30 18:46 Whitepaper.html
	#         com.apple.quarantine	   29 
        # $ xattr Whitepaper.html 
        # com.apple.quarantine
        # $ xattr -p com.apple.quarantine Whitepaper.html 
        # 0002;5928a690;Microsoft Word;
        #
        # all of which is now an obscure moot point given the argv fix;
        #------------------------------------------------------------------
        fname = None

    if hasattr(sys, 'frozen') and sys.frozen == 'macosx_app':
        # and to be sure, never use _any_ argv as a filename in Mac py2app mode;
        # this probably subsumes the prior check, but it's an afterthought;
        # [4.0] does this also apply to new PyInstaller macOS app? (TBD)
        fname = None
        
    # make main window (TextEditor+GuiMaker Frame) on Tk root, packed by GuiMaker
    root = Tk()
    text = TextEditorMain(root)            # [4.0] drop loadFirst=fname for auto close 
    if fname:
        opened = text.onOpen(fname)        # [4.0] runs cross-process already-open test        
        if not opened:                     # [4.0] already open + user declined reopen?
            text.onQuit()                  # [4.0] auto close empty edit window
            return

    startupTime = time.time()                         # epoch seconds

    # [3.0] catch doc-open events in Mac app mode
    if RunningOnMacOS and hasattr(sys, 'frozen'):     # [4.0] -sys.frozen == 'macosx_app'

        def openAllDocs(*args):
            r"""
            ---------------------------------------------------------------
            Catch Mac OpenDoc events -- received on doc clicks, Open With,
            and drag-and-drop -- and open files in the root (if received 
            at startup time) or new popup window(s) (all other receipts).

            OpenDoc events args can be > 1 if many selected and clicked
            as a group, and may come in here either just after the app
            is started, arbitrarily long after it's started, or never if
            the user clicks the app instead of a doc (Open/ReopenApp).
            This event has meaning only on Mac in frozen-app mode: for
            source code, the Python Launcher is the app, not this code.
            
            Open file in first (root) window if and only if event received 
            just after app start, else use popups with parent = Tk root so
            not auto closed with other Toplevels.  This code seems a wacky 
            heuristic, but is unavoidable: we don't want to open docs in a 
            formerly-opened root, but the user may have clicked _either_ 
            the app or a file initially, and only event-receipt time can 
            differentiate.  If PyEdit is embedded, the root is elsewhere 
            and PyEdit is not __main__, so this code is unused/irrelevant.
            
            Windows has no such requirement: either it or PyInstaller's
            bootstrap code spawns a new PyEdit process for each doc open,
            which is less functional (there is no shared state) but simple.
            Note: this is not called with no args if the app itself is
            clicked once it's already open (see the Reopen handler below).

            Also note that app startup sends either either OpenDoc if
            started via a file click or drop -XOR- OpenApp otherwise, and
            OpenDoc can be sent both at app startup and on later doc opens,
            whether the main window is in use or not.  Tk programs seem
            to register event handlers soon enough to catch either event,
            but don't use the main window for a doc _except_ at startup
            (even if user clears with File->New... and adds text or not).

            UPDATE: just like Grep matches, close a new popup window
            if the file is already open and the user opts to not reopen.
            This is debatable, but leaving an empty window under the auto
            raised window(s) where the file is already open seems odd: 
            it was usually closed manually anyhow, and a new popup can 
            always be made quickly on Macs with a new app/Dock click.

            NOTE: it's critical to _not_ open a filename in sys.argv[1]
            in app mode, as Finder/Launch Services may pass anything,
            including a '-psn_*' ProcessSerialNumber; use events instead,
            and ignore psn in argv (it is not also passed here);
                        
            NOTE: very rarely, this failed to open a first file in the 
            newly-launched app's main window for "Open With" (but not for 
            drag-and-drop); increased the delay time to 1.0 (not 0.5) to 
            compensate.  This has not been seen again, and may reflect a
            system or Tk bug, or have been a symptom of the prior note's 
            issue before it was fixed (TBD).

            SUBTLETY: this does an odd encode + decode to fix filenames 
            containing non-BMP Unicode emoji characters.  Either Tk or 
            Python's tkinter munge emojis such that os.path.isfile() in 
            onOpen() returns False, thereby causing such files to fail 
            for any Finder-based open (e.g., drag-and-drop).  Filenames
            are trashed when received here, but seem to contain encoded 
            bytes in a Python decoded str - which is why the encode/decode
            fixes all cases tested.  If this workaround ever fails, such 
            files can still be opened in PyEdit via a File->Open, per the
            popup; their name's emojis are replaced for display either way.
            Examples: docetc/examples/Assorted-demos/non-BMP-emoji-*.txt.
            Absolute pathnames received on this event look like this:
            '/.../Non-BMP-Emojis/Non-BMP-Emoji-both-\xf0\x9f\x98\x8a.txt'
            [4.0] Is this still an issue in Tk 8.6.13+?; latin1 is hmm.
           
            [4.0] Changed test to ensure this runs for PyInstaller macOS
            app where sys.frozen is True instead of py2app's 'macosx_app'.
            ---------------------------------------------------------------
            """
            print('PyEdit caught openDoc:', ascii(args), flush=True)
                    
            # may be > one if many selected and clicked
            for arg in args:
                if not os.path.isfile(arg):
                     # fix raw emoji bytes passed in str from tkinter and/or Tk
                     try:
                         arg = arg.encode('latin1').decode('utf8')
                         assert os.path.isfile(arg)   # okay now?
                     except:
                         my_showerror(root, 'OpenDoc', 
                            'OpenDoc failed for "%s"\n\n'
                            'Try opening manually with PyEdit\'s File->Open' % arg)
                         continue   # skip: onOpen() would fail too

                if (len(text.openwindows) == 1 and        # just 1 window open?
                    text.getFileName() == None and        # no file in it (yet)?
                    time.time() < (startupTime + 1.0)):   # just after startup?

                    # file 1, at startup: in already-created root window
                    print('OpenDocument: main')

                    try:
                        opened = text.onOpen(arg)
                    except Exception as why:
                        # onOpen() should catch all excs: just in case
                        print('OpenDoc root failure:', ascii(why), flush=True)
                        my_showerror(root, 'OpenDoc', 'OpenDoc failed for "%s"' % arg)

                    if not opened:        # already open + user declined reopen?
                        text.onQuit()     # auto close empty edit window

                else:
                    # files 2..N: in popup windows, parent=None=Tk root (no self)
                    # not just: TextEditorMainPopup(loadFirst=arg)
                    print('OpenDocument: popup')

                    popup = TextEditorMainPopup() 
                    try:
                        opened = popup.onOpen(arg)
                    except Exception as why:
                        # onOpen() should catch all excs: just in case
                        opened = False
                        print('OpenDoc popup failure:', ascii(why), flush=True)
                        my_showerror(popup, 'OpenDoc', 'OpenDoc failed for "%s"' % arg)

                    if not opened:        # already open + user declined reopen?
                        popup.onQuit()    # auto close empty edit window

        assert RunningOnMacOS
        root.createcommand('::tk::mac::OpenDocument', openAllDocs)


    # [3.0] catch app-reopen events in all Mac modes
    if RunningOnMacOS:
        
        def reopenApp():
            """
            ----------------------------------------------------------------
            Respond to the ReopenApplication event when running as either
            a frozen Mac app or source code.  This event is called if the
            app or its Dock entry (of the frozen app, or the Python Launcher
            for source code) is clicked while the app is already running.

            Apple defines a complex protocol for app action (lifting, etc.);
            here, we just open a new empty popup window in response, instead
            of no-op.  The app exits in full if its main window is closed,
            so we'll never receive another reopen event after that point.
            ----------------------------------------------------------------
            """
            print('PyEdit caught reopenApp', flush=True)
            TextEditorMainPopup()                    

        def openApp():
            """
            ----------------------------------------------------------------
            When an app starts, it receives OpenApp (if the app itself was
            clicked) XOR OpenDoc (if the app was started because a doc was
            clicked, drag-and-dropped, or Open-With'ed).  Ignore OpenApp,
            as the normal __main__ logic has already created a root window, 
            but catch OpenDoc above to open a file in the root window when
            that event is received at startup time (else in a new popoup).
            ----------------------------------------------------------------
            """
            print('PyEdit caught openApp', flush=True)   # stub for now

        # PP4E.Gui.Tools.guimaker also binds onQuit to tk::mac::Quit            
        root.createcommand('::tk::mac::ReopenApplication', reopenApp)
        root.createcommand('::tk::mac::OpenApplication', openApp)       
    
    # now it's the user's turn
    root.mainloop() 


if __name__ == '__main__':                            # when run as a script

    if RunningOnWindows:
        """
        ---------------------------------------------------------------------------
        [4.0] auto deblur tkinter GUIs, wheter run by python.exe or standalone exe;
        this must be run before creating any UI components in the calling process;

        an older and limited alternative of the Kivy PPUS app's code:
        from ctypes import windll
        windll.shcore.SetProcessDpiAwareness(2)    # 2=per monitor, 1=main monitor

        https://github.com/kivy/kivy/pull/7299
        https://learning-python.com/post-release-updates.html#win10blurryguis
        https://learn.microsoft.com/en-us/windows/win32/hidpi/dpi-awareness-context
        ---------------------------------------------------------------------------
        """
        from ctypes import windll, c_int64
        windll.user32.SetProcessDpiAwarenessContext(c_int64(-4))   # per monitor aware v2


    if hasattr(sys, 'frozen') and RunningOnWindows: 
        """
        ---------------------------------------------------------------------------
        [4.0] yes, must tell PyInstaller splash screen to close, after
        the main window is built.  The ss might be a custom thing here,
        but PyInstaller standalones have no control during unzip time.
        ---------------------------------------------------------------------------
        """
        import pyi_splash
        pyi_splash.close()


    """
    ---------------------------------------------------------------------------
    [3.0] Add support for using multiprocessing (MP) in Grep.
    Used for single-file PyInstaller frozen binaries on Windows: 
    - On Windows calling this function must be called here.
    - On Linux and OS X (and if not frozen) it does nothing.
    This is required only in the frozen program's main, which
    is run in the process that spawns the MP child process.
    See also multiprocessing_exe_patch.py, imported above.
    PyEdit lib clients must do this in __main__ too (PyMailGUI).
    ---------------------------------------------------------------------------
    """
    multiprocessing.freeze_support()
    

    # or testPopup()
    main()                     # run .pyw for no DOS streams box



[Home page] Books Code Blog Python Author Train Find ©M.Lutz