File: android-tkinter/CODE/textEditor.py (original) (raw)
#!/usr/bin/env python3 """ ################################################################################ PyEdit: a Python/tkinter text-file editor program and component.
-Copyright and author: © M.Lutz, 2000-2019 (http://learning-python.com) -License: provided freely, but with no warranties of any kind -Original version from the book Programming Python, 2nd-4th Editions (PP2E-PP4E)
#-------------------------------------------------------------------------------
ANDROID version, Jan-Apr 2019 (search for "# ANDROID" to view all changes).
These changes may be merged into the original code in a later release.
Recent changes (search for [date] labels to see changes made):
[Apr2119] Pydroid 3 3.0 broke webbrowser: use os.system(cmd) with a
hardcoded Android activity-manager command line instead
(3.0's DISPLAYbreaksmodule,DISPLAY breaks module, DISPLAYbreaksmodule,BROWSER kills "file://").
[Apr1919] Fix the Run-Code "Capture" workaround to the Pydroid 3 empty
sys.executable bug, to accommodate the different Python path
in Pydroid 3's 3.0. The fix now reads a spawned 'which python'
command to be path agnostic when hardcoding the Python path.
Also revert to using webbrowser for Help's user guide: it does
work, but iff files use '"file://" and HTML docs use online URLs
(but Run-Code "Click" still uses os.system); see _openbrowser.py.
[Apr1219] Reenable Help's "User Guide" button, and fix it to open with an
os.system() spawn of an activity-manager command instead of Py's
webbrowser; open online version of help, for its latest changes.
Also: shrink help dialog; work around its About/Versions truncated
text with custom scrolled-text dialogs using word wrap and sized
for fit; and open default apps on Android for Run-Code's "Click".
[Mar3119] An attempt to manually line-break some help-dialog text was
abandoned because it's impossible to get the fit right for both
orientations. Pydroid 3 tkinter truncates instead of wrapping.
[Mar2819] New user config for initial folder of Open/Save file-chooser dialog;
else, navigating to user content can be tedious in the Tk dialog.
Also trim some comments lines: Pydroid 3 can't handle larger files.
[Feb2019] Clarify Android Tk font constraints in preset fonts pick list.
#-------------------------------------------------------------------------------
Uses the Tk text widget, plus GuiMaker menus and toolbar buttons, to implement a full-featured text editor and code laucher 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:
[[py]thon] textEditor.py [filename]
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.
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.
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).
Android: ADDITIONAL DOCUMENTATION TRIMMED HERE
Because Pydroid 3's IDE editor cannot handle source files > roughly 256k
bytes (and lets the user's program die without warning!), some additional
comments were deleted here. See this file's original version for text cut,
and learning-python.com/mergeall-android-scripts/_README.html#toc85.
################################################################################ """
#===============================================================================
(Some) major [3.0] additions (also search for "[3.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+ 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.
"""
these are tedious to repeat
import sys RunningOnMac = sys.platform.startswith('darwin') RunningOnWindows = sys.platform.startswith('win') # or [:3] == 'win' RunningOnLinux = sys.platform.startswith('linux')
#===============================================================================
""" [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 configures sys.path (but not CWD) in-place as needed for the freeze tool used, to grant importers access to these items. It's a no-op for some source-code.
Also now provides a function for portably determining the install folder: use this instead of file directly, which may not work in PyInstaller executables (the function uses file for source/app, else sys.argv[0]);. Try the . import first: it's crucial that this gets its own version. """
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;
use file of this file for Mac apps, not module: it's in a zipfile;
INSTALLDIR = fixfrozenpaths.fetchMyInstallDir(file) # absolute
#===============================================================================
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).
"""
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 TkVersion <= 8.6:
return any(ord(ch) > 0xFFFF for ch in text)
else:
return False # and assume Tk 8.7 will make this better...
#===============================================================================
def try_set_window_icon(window, prog='pyedit', kind='-main'): """ [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).
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 client 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.
"""
def findicon(ext):
pyeditdir = INSTALLDIR # not __file__ if PyInstaller exe
iconscwd = glob.glob('*.%s' % ext)
namepatt = '%s-window%s.%s' % (prog, kind, ext)
iconhere = os.path.join(pyeditdir, 'icons', namepatt)
iconname = iconscwd[0] if iconscwd else iconhere
return iconname
try:
if RunningOnWindows:
window.iconbitmap(findicon('ico')) # Windows: all contexts
elif RunningOnLinux:
imgobj = PhotoImage(file=findicon('gif')) # Linux: app bar, Tk 8.5+
window.iconphoto(True, imgobj) # use Gif for Tk 8.5-
elif RunningOnMac or True:
raise NotImplementedError # Mac (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 RunningOnMac: 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: 3.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.
--------------------------------------------------------------------
[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 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
# 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
#===============================================================================
(Mostly) original PP4E code follows (but see also "[3.0]"s ahead)
#===============================================================================
Version = '3.0' # 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
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 Linux, Mac OS X, if not RunningOnWindows: # and any other non-Windows boxes FontScale = 3
ANDROID - but use smaller fonts on smaller screens
FontScale = 0
#----------------------------------------------------------------------------
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;
#----------------------------------------------------------------------------
HelpText = """PyEdit
Version ☞ %s, June 2017 (Android 2019)
A text-editor and code-launcher program and component. PyEdit is open source, uses Python 3.X and its tkinter GUI toolkit, and runs on Mac OS X, Windows, Linux, and Android.
Author and © M. Lutz 2000-2019. Originally from the book "Programming Python, 4th Edition" (a.k.a. PP4E), published by O'Reilly Media, Inc.
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, see UserGuide.html.
PyEdit Version History
● %s: Jan, 2019 (Android) ● %s: Jun, 2017 (PCs) ● 2.1: Apr, 2010 ● 2.0: Jan, 2006 ● 1.0: Oct, 2000
★ Version %s was released with Android patches in January 2019, initially.
★ Version %s adds 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, already-open checks, case toggles for searches, parallel grep processes, run-code dialog and stream capture, exe and app bundle distributions, and full utility on Mac OS X in addition to Windows and Linux.
★ Version 2.1 was released with PP4E. It addded Python 3.X code, a "grep" external-files search dialog, verified quits if any edit windows' text is changed, arbitrary Unicode encodings for files, support for multiple change and font dialogs, and upgrades to the run-code option.
★ Versions 2.0 and 1.0 appeared in PP3E and PP2E. 1.0 introduced core utility, and 2.0 added a font-pick dialog, unlimited undo/redo, smarter save prompting only if text changed, case-insensitive search, and configuration module textConfig.py."""
fill-in version number
HelpText = HelpText % ((Version,) * 5)
[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 (?)
if RunningOnWindows: HelpText = HelpText.replace('☞', '⇨') # ☞ beats ☛ on Mac; ★ on all
[3.0] on Linux, specialize too-large bullets (silly, but true);
ANDROID [Apr1219] but Linux bullets seem too small - add "False"
(caveat: larger bullet's size can vary per run; tkinter buglet?);
if False and RunningOnLinux: HelpText = HelpText.replace('●', '•') # else huge in info box on Linux
yes, these render differently on Windows/Linux and Mac...
dialogHelpBullet = '•' if RunningOnMac 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. """
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 SystemError:
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)
ftypes = [('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
startfiledir = os.environ.get('HOME', # Unix (Mac, Linux)
os.environ.get('HOMEPATH', # Windows (no HOME)
'.')) # else my source dir
# ANDROID [Mar2819] - use textConfig.py user setting for first path
# (only), if set to a valid folder (or None=internal-storage root).
# Else, starts at Pydroid 3's app-private $HOME folder in "/data/data",
# and navigating to content on first use can be tedious in Android Tk.
# '/storage/emulated/0'='/sdcard' but supports navigation to drives.
#
androidfiledir = Configs.get('filechooserstart', None)
androidfiledir = androidfiledir or '/storage/emulated/0'
if os.path.isdir(androidfiledir):
startfiledir = androidfiledir
#------------------------------------------------------
# menu Tools=>Color List presets (+ main setting):
# applies next one each time Color List is selected;
# foreground/background, colorname or #RRGGBB hexstr;
# [3.0] fg used for cursor too, else lost in dark bg;
# users can also pick colors in GUI, but temporary;
# [3.0] also now used for auto-color cycling on open;
#------------------------------------------------------
colors = [
{'fg': 'white', 'bg': '#173166'}, # color pick list
{'fg': 'black', 'bg': 'ivory'}, # ANDROID - added for fun
{'fg': '#ffff66', 'bg': 'black'}, # first item is default
{'fg': 'black', 'bg': 'lightcyan'}, # tailor these as desired
{'fg': 'white', 'bg': 'darkgreen'}, # or Pick Bg/Fg chooser
{'fg': 'white', 'bg': '#800040'}, # maroon - or so they say
{'fg': 'black', 'bg': '#e4c0a7'}, # light mocha
{'fg': 'white', 'bg': '#008080'}, # teal
{'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?
{'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
{'fg': 'orange', 'bg': 'navy'},
{'fg': '#ffffff', 'bg': '#633025'}, # more browns
{'fg': 'black', 'bg': 'beige'}] # last is preset fg/bg
if 'colorlist' in Configs:
colors = Configs['colorlist'] # [3.0] get from textConfig file if set
#------------------------------------------------------
# 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;
#------------------------------------------------------
# ANDROID - none of the fonts marked '###' work, and courier (only)
# ignores bold and italic styles, in both font strings and tuples;
# working: courier, times, helvetica (and monaco=courier, arial=helvetic);
# added times/helvetica bold/italic and others here for demo on Android;
# [Feb2019] updated for new findings on Android family/style constraints;
fonts = [
('courier', 4+FontScale, 'normal'), # cross-platform, mostly
('courier', 6+FontScale, 'normal'),
('courier', 8+FontScale, 'normal'),
('courier', 10+FontScale, 'normal'), # (family, size, style)
('courier', 10+FontScale, 'bold'), # bold/italoc ignored
('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
('times', 12+FontScale, 'italic'), # tbd: show in listbox?
('times', 12+FontScale, 'bold'),
('times', 12+FontScale, 'italic bold'),
('helvetica', 10+FontScale, 'normal'), # also 'underline',...
('helvetica', 10+FontScale, 'italic'),
('helvetica', 10+FontScale, 'bold'),
('helvetica', 10+FontScale, 'bold italic'),
('arial', 10+FontScale, 'normal'), # arial==helvetica
('courier', 16+FontScale, 'bold'), # bold ignored
('courier', 18+FontScale, 'normal'),
('helvetica', 10+FontScale, 'underline'),
('monaco', 12+FontScale, 'normal'), # monaco==courier, fixed-width
### ('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__.
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 created 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 session-global
#self.saveDialog = None
# [3.0] update() is no longer required: see setAllText()
if loadFirst:
#self.update() # 2.1: else @ line 2; see book
self.onOpen(loadFirst, loadEncode) # this might not open a file
# [3.0] auto-save filename ids 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 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.
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
runcode = 'Run ⚙'
popup = 'Pop☝' if not RunningOnLinux else 'Pop ☝' # need a space?
info = ' ⅈ ' if RunningOnMac else 'Info' # Mac fonts rule
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+-?'] + ['⏎']
# ANDROID - make hieroglyphs a bit bigger via Mac setting + ? spaces
bg, fg, inc, dec, pick, wrap = ('⇧', '⇩', '⇧', '⇩', ' ? ', '⏎')
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, ⅈ?
(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), # rarely used?
(fg, self.onPickFg, packLeft),
'<---',
('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), # Pick, ⇳, ⇵, …, ⌨
(wrap, self.onLineWrap, packLeft) # or Wrap, ⏎, ↲
# then right-side spacer after Run
]
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'):
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
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 # redundant but descriptive
self.filelabel = name # save widgets for changing
def autoSaveLoop(self):
"""
------------------------------------------------------------------------
[3.0] If configured to do so, every 5 minutes (by default) 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:
-- 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 in a session,
and a process id makes them unique across sessions
-- 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...
------------------------------------------------------------------------
"""
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(pathname):
"""
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.
"""
namemax = 255 # common denominator
filename = os.path.basename(pathname)
dirpath = os.path.dirname(pathname)
dirpath = dirpath.replace(os.sep, '_')
dirpath = dirpath.replace(':', '_')
savename = '%s--AT--%s' % (filename, dirpath)
if len(savename) > namemax:
savename = savename[:namemax - 3] + '...'
return savename
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 # nothing 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:
knowname = window.getFileName()
if knowname:
# use known file+path
filename = savename(knowname)
else:
# create a fake name
count = window.namelessid # unique in session
mypid = os.getpid() # unique on machine
filename = '_nameless-%d-%d.txt' % (count, mypid)
# 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;
"""
# make dialog object first time
if not self.openDialog:
title = self.appname + ': Open File'
if RunningOnMac:
dlgargs = dict(
message=title, # Mac open ignores 'title'
initialdir=self.startfiledir) # Mac fails on 'filetypes'
else:
dlgargs = dict(
title=title, # Windows+Linux use title
initialdir=self.startfiledir, # Windows fails on 'message'
filetypes=self.ftypes)
TextEditor.openDialog = Open(**dlgargs)
# disable prior file/path name picks having emojis: kills dialog
fixBMPargs = self.fixTkBMP_FileDialogs(self.openDialog)
# run the dialog, restore focus
choice = self.openDialog.show(
parent=self, # don't lift root window, use Mac sheet
**fixBMPargs) # avoid non-BMP Unicode failures
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;
"""
# make dialog object first time
if not self.saveDialog:
title = self.appname + ': Save File'
dlgargs = dict(
title=title, # save uses title on all 3
initialdir=self.startfiledir, # filetypes okay on Mac:
filetypes=self.ftypes) # greyed out but selectable
TextEditor.saveDialog = SaveAs(**dlgargs)
# disable prior file/path name picks having emojis: kills dialog
fixBMPargs = self.fixTkBMP_FileDialogs(self.saveDialog)
# run the dialog, restore focus
choice = self.saveDialog.show(
parent=self, # don't lift root window, use Mac sheet
**fixBMPargs) # avoid non-BMP Unicode failures
dlgRefocus(self) # [3.0] else Mac needs click if Cancel
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
@modalMenuAction
def onOpen(self, loadFirst='', loadEncode=''):
"""
----------------------------------------------------------------------
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 wih defaults)
3) if opensEncoding nonempty, try this encoding next: 'latin-1', etc.
4) tries sys.getdefaultencoding() platform default next
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;
----------------------------------------------------------------------
"""
if self.text_edit_modified(): # 2.0
if not my_askyesno(self, 'Open', 'Text has changed: discard changes?'):
return
file = loadFirst or self.my_askopenfilename()
if not file:
return
if not os.path.isfile(file): # [3.0] links to files are okay too
my_showerror(self, 'Open', 'Could not open file ' + file)
return
# [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)?
match = os.path.abspath(file)
openwindows = [w for w in TextEditor.openwindows if w.currfile == match]
if openwindows:
self.update()
if my_askyesno(self, 'Open', 'File already open: reopen anyhow?'):
# continue with duplicate open
pass
else:
# raise already-open instance(s)
self.liftWindows(openwindows) # may be > 1 if reopened
return # some callers may onQuit() now
# try known encoding if passed and accurate (e.g., email)
text = None # empty file = '' = False: test for None!
if loadEncode:
try:
text = open(file, 'r', encoding=loadEncode).read()
self.knownEncoding = loadEncode
except (UnicodeError, LookupError, IOError): # lookup: bad name
pass
# try user input, prefill with next choice as default
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
sys.getdefaultencoding() or ''))
self.text.focus() # else must click (now auto)
if askuser:
try:
text = open(file, 'r', encoding=askuser).read()
self.knownEncoding = askuser
except (UnicodeError, LookupError, IOError):
pass
# else return? no - more options ahead
# try config file (or before ask user?)
if text == None and self.opensEncoding:
try:
text = open(file, 'r', encoding=self.opensEncoding).read()
self.knownEncoding = self.opensEncoding
except (UnicodeError, LookupError, IOError):
pass
# try platform default (utf-8 on windows; try utf8 always?)
if text == None:
try:
text = open(file, 'r', encoding=sys.getdefaultencoding()).read()
self.knownEncoding = sys.getdefaultencoding()
except (UnicodeError, LookupError, IOError):
pass
# last resort: use binary bytes and rely on Tk to decode
if text == None:
try:
text = open(file, '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 ' + file)
else:
self.setAllText(text)
self.setFileName(file)
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);
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 '
'that have been replaced for display. Saving its text '
'to a file may result in loss of the characters replaced. '
'See the User Guide\'s "About emojis" for details.')
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):
"""
----------------------------------------------------------------------
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
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;
----------------------------------------------------------------------
"""
filename = forcefile or self.my_asksaveasfilename()
if not filename:
return
# 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 known encoding at latest Open or Save, if any
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
# try user input, prefill with known type, else next choice
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
sys.getdefaultencoding() 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
# try config file
if not encpick and self.savesEncoding:
try:
text.encode(self.savesEncoding)
encpick = self.savesEncoding
except (UnicodeError, LookupError):
pass
# try platform default (utf8 on windows)
if not encpick:
try:
text.encode(sys.getdefaultencoding())
encpick = sys.getdefaultencoding()
except (UnicodeError, LookupError):
pass
# open in text mode for endlines + encoding
if not encpick:
my_showerror(self, 'Save', 'Could not encode for file ' + filename)
else:
try:
file = open(filename, 'w', encoding=encpick)
file.write(text)
file.close()
except:
my_showerror(self, 'Save', 'Could not write file ' + filename)
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
"""
return True # iff actually saved a file (else returns 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
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;
"""
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
where = self.text.search(key, INSERT, END, nocase=nocase)
if not where: # don't wrap
my_showinfo(self, 'Find', 'String not found')
else:
pastkey = where + '+%dc' % len(key) # index past key
self.text.tag_remove(SEL, START, END) # remove any sel
self.text.tag_add(SEL, where, pastkey) # select key
self.text.mark_set(INSERT, pastkey) # for next find
self.text.see(where) # scroll display
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;
-----------------------------------------------------------------------------
"""
new = Toplevel(self) # pertains to and closed with self
try_set_window_icon(new) # [3.0] icons (and leave resizable)
new.title('PyEdit - Find/Change')
# [3.0] call guimaker's Mac menubar fixer for nonmodal dialog window
fixAppleMenuBarChild(new)
Label(new, text='Find text?', relief=RIDGE, width=15).grid(row=0, column=0)
Label(new, text='Change to?', relief=RIDGE, width=15).grid(row=1, column=0)
entry1 = Entry(new, width=30)
entry2 = Entry(new, width=30)
entry1.grid(row=0, column=1, sticky=EW)
entry2.grid(row=1, column=1, sticky=EW)
# local callback handlers use names in enclosing method's scope
# all three lift() so that dialog isn't covered by text window
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 = entry1.get() # [3.0] don't trigger Find popup
if not findstr:
my_showerror(self, 'Find/Change', 'Please enter a Find string')
else:
self.onFind(findstr, nocase) # runs normal find dialog callback
new.lift() # [3.0] raise above text window
def onChange():
"""
replace last found text and refind
propagate the case toggle for the refind
"""
nocase = not caseSensVar.get() # [3.0] pass toggle's inverse too
findstr = entry1.get() # [3.0] don't trigger Find popup
changeto = entry2.get()
if not findstr:
my_showerror(self, 'Find/Change', 'Please enter a Find string')
else:
self.onDoChange(findstr, changeto, nocase)
new.lift()
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
new.lift()
Button(new, text='Find', command=onFind ) .grid(row=0, column=2, sticky=EW)
Button(new, text='Change', command=onChange).grid(row=1, column=2, sticky=EW)
new.columnconfigure(1, weight=1) # expandable entries
# [3.0] add usage help hints pulldown (dialog-specific: not a popup)
helptext = [
'This stay-up dialog allows you to both find and 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:',
'',
'%s Find (search string)' % dialogHelpBullet,
' Searches ahead for the next appearance of the first string,',
' and highlights and selects it, but does not replace it.',
'',
'%s Change (replacement string)' % dialogHelpBullet,
' Replaces the last-found and highlighted string with the second',
' string and searches ahead for the next occurrence of the first.',
'',
'Repeated Finds refind and select the string but do not replace it.',
'Repeated Changes replace and refind the string on each new press.',
'In all cases, searches look for a literal string, not a pattern.',
'',
'Searches run from current cursor location to end of file; click any',
"text to set cursor, or 'Top' to jump to top of file to search anew.",
'',
"In this dialog, finds are case-insensitive ('a' ==' A') by default;",
"turn the 'Case?' toggle on to match case exactly ('a' != 'A').",
'',
'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.",
'',
"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.'
]
hlpfrm = Frame(new)
hlpfrm.grid(row=2, columnspan=2) # need frame to pack child
self.addDialogHelp(hlpfrm, hlpfrm, helptext) # see grep, Escape=Help?
# [3.0] add Top button for manual wrap-around and re-search
Button(hlpfrm, text='Top', command=onTop).pack(side=RIGHT, anchor=NE)
# [3.0] add case-sensitivity toggle, next to new help
caseSensVar = IntVar()
chk = Checkbutton(new, text='Case?')
chk.config(variable=caseSensVar)
caseSensVar.set(0)
chk.grid(row=2, column=2, sticky=N)
# [3.0] save the user an initial click (focus_set is focus)
entry1.focus_set()
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 appear
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;
--------------------------------------------------------------------
"""
from PP4E.Gui.ShellGui.formrows import makeFormRow
# nonmodal dialog: get dirnname, filenamepatt, grepkey
popup = Toplevel() # stays open: not closed with self
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)
# [3.0] implement and use folder browse button for directory root
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 = makeFormRow(popup, label='Search string', width=18, browse=False)
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(sys.getdefaultencoding()) # for file content, not filenames
# [3.0] add case-sesitivity toggle, off by default
case = IntVar()
chkb = Checkbutton(popup, 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
self.onDoGrep(
var1.get(), var2.get(), var3.get(), var4.get(), case.get())
btnfrm = Frame(popup)
btnfrm.pack(side=TOP)
sbtn = Button(btnfrm, 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)
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".',
'',
'Searches are run in parallel processes 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:',
'',
'%s Directory root' % dialogHelpBullet,
' 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.",
'',
'%s Filename pattern' % dialogHelpBullet,
' The name pattern of the files you wish to search in the folder. Use',
' *=any substring, ?=any character, [seq]/[!seq]=any in/not in seq, and',
' any other characters match literally. Enclose any special characters',
' in brackets (e.g., x[?]y). Filename case-sensitivity is per platform.',
' 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.',
'',
'%s Search string' % dialogHelpBullet,
' The string you wish to search for in all matching files in the folder.',
' A literal string (not pattern), matched case-insensitively by default',
" ('a' == 'A'). Set 'Case?' toggle on to match case exactly ('a' != 'A')." ,
'',
'%s Content encoding' % dialogHelpBullet,
' The name of the Unicode text encoding to apply when reading all files,',
" prefilled with your platform's default. utf-8 is common and handles",
' ASCII too, but some files may require others (e.g., 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).',
'',
'Double-Click lines in the post-search popup to goto 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]".',
'',
'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.',
'',
'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 is often useful on errors.',
'',
'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."
]
self.addDialogHelp(popup, btnfrm, helptext)
def addDialogHelp(self, popup, btnfrm, helptext):
"""
[3.0] add a Help button to 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 nonresizable if possible, else help may be munged;
"""
from tkinter.scrolledtext import ScrolledText
helpopen = False
def onFontHelp():
# vars in per-call/dialog enclosing scope, not per-editor self
nonlocal helpopen
if not helpopen:
helpfrm.pack(side=BOTTOM, fill=X, padx=20, pady=20)
else:
helpfrm.pack_forget()
helpopen = not helpopen # toggle on/off on each call
hbtn = Button(btnfrm, text='Help', command=onFontHelp)
hbtn.pack(side=LEFT)
popup.bind('<Escape>', lambda event: onFontHelp()) # [3.0] Escape=Help
helpfrm = Frame(popup, border=2, relief=RIDGE)
display = ScrolledText(helpfrm,
height=min(20, len(helptext)),
width=max(len(line) for line in helptext)+1)
display.insert(END, '\n'.join(helptext))
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;
--------------------------------------------------------------------
"""
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)
# [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)
myqueue = queue.Queue()
grepargs += (myqueue,)
_thread.start_new_thread(grepThreadProducer, grepargs)
elif spawnMode == 'threading':
# enhanced thread module (original coding: crashes?)
myqueue = queue.Queue()
grepargs += (myqueue,)
threading.Thread(target=grepThreadProducer, args=grepargs).start()
elif spawnMode == 'multiprocessing':
# thread-like processes module (slower startup, faster overall?)
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;
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. A 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
# [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)
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)
# [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
"""
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')
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
"""
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
def onColorList(self):
"""
pick next color pair in configurable list
"""
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()?);
"""
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)
var1 = makeFormRow(popup, label='Family', browse=False, width=18)
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)
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:',
'',
'%s Family' % dialogHelpBullet,
' Use courier, times, helvetica, arial, consolas, calibri, inconsolata, menlo,...',
' 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.',
' For fixed-width text like program code, try menlo or monaco on Macs, consolas',
' on Windows, inconsolata on Linux, or courier on all three. A font.families()',
' in a running Python/tkinter program lists all available font families.',
'',
'%s Size' % dialogHelpBullet,
' Use 9, 12, 18, 20, 0, -30,...',
' Where N=points, -N=pixels, 0=platform default, and empty=0.',
'',
'%s Styles' % dialogHelpBullet,
' Use any of (bold or normal), (italic or roman), underline, or overstrike.',
' Default values are normal (i.e., nonbold) and roman (i.e., nonitalic).',
'',
'Example inputs (do not input quotes added here for clarity only):',
'',
' ["arial, "9", ""]',
' ["courier", "12", "bold"]',
' ["monaco", "12", "normal"]',
' ["times", "0", "normal italic"]',
' ["courier new", "-20", "bold roman underline"]',
'',
"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.',
'',
"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."
]
self.addDialogHelp(popup, btnfrm, helptext) # see grep, Escape=Help
popup.deiconify() # [3.0] unhide flash-free
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);
"""
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
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')
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;
"""
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;
"""
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 new 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
try_set_window_icon(popup) # icons where supported
popup.title('PyEdit - Run Code')
#popup.resizable(width=False, height=False) # need resizes for cmd args
fixAppleMenuBarChild(popup) # Mac menubar fixer for dialogs
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()
modes = ['Console ⚕', 'Click', 'Click+Keep', 'Capture ⚕']
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)
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.",
"",
"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.",
"",
"USAGE",
"",
"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.",
"",
"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.",
"",
"RUN MODES",
"",
"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:",
"",
"%s Console (Python)" % dialogHelpBullet,
"",
" 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.",
"",
" 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 Mac OS X), or use",
" Capture mode below for more control over streams and paths.",
"",
"%s Click (any code)" % dialogHelpBullet,
"",
" 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.",
"",
" 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., Mac), 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.",
"",
"%s Click+Keep (any code, Windows only)" % dialogHelpBullet,
"",
" 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 (Mac, Linux),",
" this mode is not available; use one of the other modes to launch your code.",
"",
"%s Capture (☚ recommended, Python)" % dialogHelpBullet,
"",
" 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.",
"",
" 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.",
"",
" 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.",
"",
" 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.",
"",
"CONFIGURATION",
"",
"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.",
"",
"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.",
"",
"EXAMPLES",
"",
"For precoded examples you can try in Run Code, see the files and README.txt in",
"PyEdit's install folder docetc/examples/RunCode-examples."
]
self.addDialogHelp(popup, btnfrm, helptext) # see grep, Escape=Help
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
#
# ANDROID - sys.executable is empty in Pydroid 3: Popen fails if not set here
#
# ANDROID [Apr1919]: Pydroid 3's 3.0 release moved its Python from the
# first of the following paths to the second, breaking this workaround:
# /data/user/0/ru.iiec.pydroid3/files/arm-linux-androideabi/bin/python
# /data/user/0/ru.iiec.pydroid3/files/aarch64-linux-android/bin/python
# to allow for both paths--and be platform agnostic in general--read the
# result of a 'which python' shell command instead of using literal strs;
#
# note: this sets the py exe path globally and intentionally: this fixes
# the Pydroid 3 bug for spawnees that spawn commands or scripts too;
#
sys.executable = os.popen('which python').read().rstrip() # path to Python exe
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 fozen proxy exe?
noPythonExe = (
hasattr(sys, 'frozen') and # frozen exe PyEdit package?
sys.frozen != 'macosx_app' and # not Mac app (has a python)?
userpython == None) # and no user python config?
if runmode == 'Console ⚕':
#-----------------------------------------------------------------
# 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:
my_showinfo(popup, 'Run Code',
'Sorry — Console mode is not available in frozen PyEdits '
'on Windows and Linux unless you give an installed Python '
'in your textConfig.py file. 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 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':
#-----------------------------------------------------------------
# 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;
#-----------------------------------------------------------------
#
# ANDROID [Apr1219]: do something marginally useful on Android;
# spawns an "am" activity-manager command line (see onUserGuide),
# 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);
#
# ANDROID [Apr1919]: webbrowser.open() would spawn the same command
# to open the URL (via subprocess.Popen) but has no advantage here;
#
# ANDROID [Apr2119]: Pydroid 3 3.0 broke webbrowser AND changed
# $BROWSER to skip all "file://" - keep os.system, hardcode cmd;
#
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']
# other platforms code...
"""
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':
#-----------------------------------------------------------------
# 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. '
'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 ⚕':
#-----------------------------------------------------------------
# [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. To use Capture mode in its complete '
'form, get the full standalone PyEdit program at:\n\n'
' http://learning-python.com/pyedit')
return # run code not supported here
"""
delete me soon.....................................................
# if not source code and not own PyEdit frozen app or exe
# --or-- source code but part of a frozen Mac app (PyMailGUI);
# __name__ == '__main__' won't help: ok if embed in source;
# sys.executable won't help: may be an app bundle python;
if (RunningOnMac 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 RunningOnMac:
runcodewarn_mac() # other Mac app embedders?
# but continue
elif RunningOnWindows or RunningOnLinux:
runcodepunt_winlin() # PyMailGUI Windows/Linux exes
return # run code not supported here
...................................................................
"""
#+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# 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.
-------------------------------------------------------------
"""
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:
# 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 RunningOnMac:
# 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.
-------------------------------------------------------------
"""
if (userpython != None and # user-configured Python
hasattr(sys, 'frozen') and # a frozen PyEdit running
sys.frozen != 'macosx_app'): # but not a Mac app 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;
#-------------------------------------------------------------------
stdoutwindow = Toplevel(self) # child of self: closes
if noPythonExe: # frozen proxy subproc?
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
# 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 app);
# 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 with python executable;
# use python set in textConfig, else python running PyEdit;
# this branch is also used for frozen Mac apps, and when a
# Python executable is set in textConfigs.py: use .py source;
# proxy script file 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 RunningOnMac and hasattr(sys, 'frozen') and userpython:
# force py2app Mac 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);
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:
# 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 run a frozen PyInstaller exe: not Mac 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)
else:
subprocTempdir = None
#-------------------------------------------------------------------
# __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
############################################################################
# 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.
------------------------------------------------------------------
"""
def androidTextDisplay(title, helptext):
"""
# ANDROID [Apr1219] - work around truncated common-dialog text bug,
# by using a word-wrapped scrolled-text widget instead of showinfo;
# also set font for fit on smaller (~5.5") phones with large defaults;
"""
from tkinter.scrolledtext import ScrolledText
popup = Toplevel()
popup.title('PyEdit %s - %s' % (Version, title))
ok = Button(popup, text='OK', command=popup.destroy)
ok.pack(side=BOTTOM) # pack first=clip last
text = ScrolledText(popup, wrap='word') # wrap on word boundaries
text.pack(expand=YES, fill=BOTH)
text.insert(END, helptext)
text.config(font='courier 5 normal') # else some phones default larger
text.config(width=48, height=24) # start small for fit: chars, lines
text.config(state=DISABLED) # make read-only: avoid os-keyboard
@modalMenuAction
def onAbout():
"""
display text in a modal popup
original version help, half1 (force popup on Mac, not slide-down)
"""
# ANDROID [Apr1219] - use custom dialog to avoid truncation
androidTextDisplay('About', HelpText_About)
# other platforms legacy code...
"""
orphan = RunningOnMac
my_showinfo(popup, 'About', HelpText_About, orphan=orphan)
"""
@modalMenuAction
def onVersions():
"""
display text in a model popup
original version help, half2 (force popup on Mac, not slide-down)
"""
# ANDROID [Apr1219] - use custom dialog to avoid truncation
androidTextDisplay('Versions', HelpText_Versions)
# other platforms legacy code...
"""
orphan = RunningOnMac
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;
"""
myreadme = os.path.join(mysourcedir, '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 [Apr1219]: webbrowser fails on Android (for reasons TBD),
# so spawn a shell command using the $BROWSER preset in Pydroid 3:
# "am start --user 0 -a android.intent.action.VIEW -d %s"; Android
# uses online version to pick up latest changes (others should too);
#
# ANDROID [Apr1919]: webbrowser _does_ work, but requires local file
# URLs to start with "file://" and does not open a web browser for
# local HTML files (they open in text editors); use the online URL
# to ensure a web browser, and either os.system or webbrowser.open;
#
# ANDROID [Apr2119]: Pydroid 3 3.9 broke webbrowser and changed
# $BROWSER - use os.system again, with hardcoded command line;
#
myuserguide = ('https://www.learning-python.com'
'/pyedit-products/unzipped/UserGuide.html')
brw = 'am start --user 0 -a android.intent.action.VIEW -d %s'
cmd = brw % myuserguide
os.system(cmd)
# other platforms code...
"""
import webbrowser
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')
"""
# get source dir from __file__, whether embedded or standalone;
# update: uses __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
popup.title('PyEdit %s - Help' % Version)
popup.appname = 'PyEdit' # for callDialog (non-TextEditor)
dlgfont = 'helvetica'
tagline = ' PyEdit \u2014 Edit text. Run code. Have fun.'
#
# ANDROID [Apr1219] - smaller font for fit, was 18
#
Label(popup, text=tagline, bg='white',
font=(dlgfont, 10, '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
#
# ANDROID [Apr1219] - smaller font for fit, was 14
#
btnfont = (dlgfont, 8, 'bold')
Button(popup, text='About',
font=btnfont, bg='white',
command=onAbout).pack(padx=10, pady=10)
Button(popup, text='Versions',
font=btnfont, bg='white',
command=onVersions).pack(padx=10, pady=10)
Button(popup, text='Readme',
font=btnfont, bg='white',
command=onReadme).pack(padx=10, pady=10)
# ANDROID [Apr1219] - most colored buttons also lose their bg on presses,
# though only the user-guide button does here: use a label+bind instead;
#
uglab = Label(popup, text='User Guide',
font=btnfont, bg='white',
relief=SOLID,
width=12, height=2) # forge button (but a bit larger...)
uglab.pack(padx=10, pady=10)
uglab.bind('<Button-1>', lambda event: onUserGuide())
# other platforms code...
"""
Button(popup, text='User Guide',
#state=DISABLED, # ANDROID - webbrowser failed initially
font=btnfont, bg='white',
command=onUserGuide).pack(padx=10, pady=10)
"""
Button(popup, text='Close Help',
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.
################################################################################
""" #
Android: ADDITIONAL DOCUMENTATION TRIMMED HERE
Because Pydroid 3's IDE editor cannot handle source files > roughly 256k
bytes (and lets the user's program die without warning!), some additional
comments were deleted here. See this file's original version for text cut,
and learning-python.com/mergeall-android-scripts/_README.html#toc85.
2.1: Quit protocol notes
[3.0] Top-level class updates and notes
"""
#*******************************************************************************
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) # use main window menus
try_set_window_icon(self.master) # [3.0] set (some) icons
wintype = ' ✍' #if RunningOnMac else '' # [3.0] distinguish (or ✐)
fulltitle = 'PyEdit %s - Main' + wintype # use diff icon on Win/Lin
self.master.title(fulltitle % Version) # title on parent win
self.master.iconname('PyEdit')
# 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 requested in GUI: 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;
"""
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
doquit = True
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
# 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) # use main window menus
assert self.master == self.popup
try_set_window_icon(self.popup, kind='-popup') # [3.0] set (some) icons
winTitle = winTitle or 'Popup' # [3.0] '' if popup Clone
wintype = ' ☝' #if RunningOnMac else '' # [3.0] distinguish (or ⚐, ⇧)
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 window
[3.0] called for window's file-menu or toolbar Quit (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:
# 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;
------------------------------------------------------------------------
"""
def __init__(self, parent=None, loadFirst='', loadEncode=''):
"""
embedded, Frame-based menus
"""
GuiMaker.__init__(self, parent) # all menus, buttons on
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.
------------------------------------------------------------------------
"""
def __init__(self, parent=None, loadFirst='', deleteFile=True, loadEncode=''):
"""
embedded, Frame-based menus, no File/Quit
"""
self.deleteFile = deleteFile
GuiMaker.__init__(self, parent) # GuiMaker Frame packs self
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.
--------------------------------------------------------------------------
"""
import time
try:
fname = sys.argv[1] # arg = optional filename
except IndexError: # Mac app uses doc events
fname = None
if RunningOnMac 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 App mode;
# this probably subsumes the prior check, but it's an afterthought;
fname = None
# make main window on Tk root (pack optional)
root = Tk()
text = TextEditorMain(root, loadFirst=fname) # Tk(TextEditor+GuiMaker)
text.pack(expand=YES, fill=BOTH)
startupTime = time.time() # epoch seconds
# [3.0] catch doc-open events in Mac app mode
if hasattr(sys, 'frozen') and sys.frozen == 'macosx_app':
def openAllDocs(*args):
"""
---------------------------------------------------------------
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'
---------------------------------------------------------------
"""
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
try:
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)
else:
# files 2..N: in popup windows, parent=None=Tk root (no self)
# not just: TextEditorMainPopup(loadFirst=arg)
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/covered edit window
assert RunningOnMac
root.createcommand('::tk::mac::OpenDocument', openAllDocs)
# [3.0] catch app-reopen events in all Mac modes
if RunningOnMac:
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
#---------------------------------------------------------------
# [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 box