#!/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 $DISPLAY breaks module, $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 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 ') TextEditor.openwindows.remove(self) self.text.bind('', 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+,] 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' 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('', 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('', 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('', 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('', 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("") # 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. 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('', 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('', 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('', # ? 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('', 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 @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 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 ( 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 @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) # 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 ; [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 @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 # 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 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 ; 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 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); 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