Source code for pyworkflow.gui.text

# **************************************************************************
# *
# * Authors:     J.M. De la Rosa Trevin (delarosatrevin@scilifelab.se) [1]
# *
# * [1] SciLifeLab, Stockholm University
# *
# * This program is free software: you can redistribute it and/or modify
# * it under the terms of the GNU General Public License as published by
# * the Free Software Foundation, either version 3 of the License, or
# * (at your option) any later version.
# *
# * This program is distributed in the hope that it will be useful,
# * but WITHOUT ANY WARRANTY; without even the implied warranty of
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# * GNU General Public License for more details.
# *
# * You should have received a copy of the GNU General Public License
# * along with this program.  If not, see <https://www.gnu.org/licenses/>.
# *
# *  All comments concerning this program package may be sent to the
# *  e-mail address 'scipion@cnb.csic.es'
# *
# **************************************************************************
"""
Text based widgets.
"""


import os
import sys
import time
import webbrowser
import subprocess
import tkinter.ttk as ttk
import tkinter as tk
import tkinter.messagebox as tkMessageBox

import pyworkflow as pw
from pyworkflow import ASCII_COLOR_2_TKINTER
from pyworkflow.utils import (HYPER_BOLD, HYPER_ITALIC, HYPER_LINK1, HYPER_LINK2,
                              parseHyperText, renderLine, renderTextFile,
                              which, envVarOn, expandPattern)
from pyworkflow.utils.properties import Message, Color, Icon
from . import gui
from .widgets import Scrollable, IconButton
from .tooltip import ToolTip


# Define a function to open files cleanly in a system-dependent way
if sys.platform.startswith('darwin'):  # macs use the "open" command
    def _open_cmd(path, tkParent=None):
        subprocess.Popen(['open', path])
elif os.name == 'nt':  # there is a function os.startfile for windows
    def _open_cmd(path, tkParent=None):
        os.startfile(path)
elif os.name == 'posix':  # linux systems and so on
[docs] def find_prog(*args): """Return the first argument that is a program in PATH""" for command in args: if which(command): return command return None
x_open = find_prog('xdg-open', 'gnome-open', 'kde-open', 'gvfs-open') editor = pw.Config.SCIPION_TEXT_EDITOR if not editor: editor = find_prog('pluma', 'gedit', 'kwrite', 'geany', 'kate', 'emacs', 'nedit', 'mousepad', 'code') def _open_cmd(path, tkParent=None): # If it is an url, open with browser. if path.startswith('http://') or path.startswith('https://') or path.endswith('.html'): try: webbrowser.open_new_tab(path) return except: pass # OK, it is a file. Check if it does exist # and notify if it does not if not os.path.isfile(path): try: # if tkRoot is null the error message may be behind # other windows tkMessageBox.showerror("File Error", # bar title "File not found\n(%s)" % path, # message parent=tkParent) return except: return if x_open: # standard way to open proc = subprocess.Popen([x_open, path]) time.sleep(1) if proc.poll() in [None, 0]: return # yay! that's the way to do it! if editor: # last card: try to open it in an editor proc = subprocess.Popen([editor, path]) time.sleep(1) if proc.poll() in [None, 0]: return # hope we found your fav editor :) print('WARNING: Cannot open %s' % path) # nothing worked! :( else: def _open_cmd(path, tkParent=None): try: tkMessageBox.showerror("Unknown System", # bar title 'Unknown system, so cannot open %s' % path, # message parent=tkParent) return except: pass
[docs]class HyperlinkManager: """ Tkinter Text Widget Hyperlink Manager, taken from: http://effbot.org/zone/tkinter-text-hyperlink.htm """ def __init__(self, text): self.text = text self.text.tag_config("hyper", foreground=pw.Config.SCIPION_MAIN_COLOR, underline=1) self.text.tag_bind("hyper", "<Enter>", self._enter) self.text.tag_bind("hyper", "<Leave>", self._leave) self.text.tag_bind("hyper", "<Button-1>", self._click) self.reset()
[docs] def reset(self): self.links = {}
[docs] def add(self, action): # add an action to the manager. returns tags to use in # associated text widget tag = "hyper-%d" % len(self.links) self.links[tag] = action return "hyper", tag
def _enter(self, event): self.text.config(cursor="hand2") def _leave(self, event): self.text.config(cursor="") def _click(self, event): for tag in self.text.tag_names(tk.CURRENT): if tag[:6] == "hyper-": self.links[tag]() return
[docs]class Text(tk.Text, Scrollable): """ Base Text widget with some functionality that will be used by children classes. """ def __init__(self, master, **opts): if 'handlers' in opts: self.handlers = opts.pop('handlers') else: self.handlers = {} opts['font'] = gui.fontNormal defaults = self.getDefaults() defaults.update(opts) Scrollable.__init__(self, master, tk.Text, wrap=tk.WORD, **opts) self._createWidgets(master, **defaults) self.configureTags() def _createWidgets(self, master, **opts): """This is an internal function to create the Text, the Scrollbar and the Frame""" # create a popup menu self.menu = tk.Menu(master, tearoff=0, postcommand=self.updateMenu) self.menu.add_command(label="Copy to clipboard", command=self.copyToClipboard) self.menu.add_command(label="Open path", command=self.openFile) # Associate with right click self.bind("<Button-1>", self.onClick) self.bind("<Button-3>", self.onRightClick)
[docs] def getDefaults(self): """This should be implemented in subclasses to provide defaults""" return {}
[docs] def configureTags(self): """This should be implemented to create specific tags""" pass
[docs] def addLine(self, line): """Should be implemented to add a line """ self.insert(tk.END, line + '\n')
[docs] def addNewline(self): self.insert(tk.END, '\n')
[docs] def goBegin(self): self.see(0.0)
[docs] def goEnd(self): self.see(tk.END)
[docs] def isAtEnd(self): return self.scrollbar.get() == 1.0
[docs] def clear(self): self.delete(0.0, tk.END)
[docs] def getText(self): textWithNewLine = self.get(0.0, tk.END) # Remove the last new line return textWithNewLine.rstrip('\n')
[docs] def setText(self, text): """ Replace the current text with new one. """ self.clear() self.addText(text)
[docs] def addText(self, text): """ Add some text to the current state. """ if isinstance(text, list): for line in text: self.addLine(line) else: for line in text.splitlines(): self.addLine(line)
[docs] def onClick(self, e=None): self.selection = None self.selection_clear() self.menu.unpost()
[docs] def onRightClick(self, e): try: self.selection = self.selection_get().strip() self.menu.post(e.x_root, e.y_root) except tk.TclError as e: pass
[docs] def copyToClipboard(self, e=None): self.clipboard_clear() self.clipboard_append(self.selection)
[docs] def openFile(self): # What happens when you right-click and select "Open path" self.openPath(self.selection)
[docs] def openPath(self, path): """Try to open the selected path""" path = expandPattern(path) # If the path is a dir, open it with scipion browser dir <path> if os.path.isdir(path): dpath = (path if os.path.isabs(path) else os.path.join(os.getcwd(), path)) subprocess.Popen([pw.PYTHON, pw.getViewerScript(), dpath]) return # If it is a file, interpret it correctly and open it with DataView dirname = os.path.dirname(path) fname = os.path.basename(path) if '@' in fname: path = os.path.join(dirname, fname.split('@', 1)[-1]) else: path = os.path.join(dirname, fname) if os.path.exists(path) or path.startswith("http"): from pwem import emlib fn = emlib.FileName(path) if fn is not None and (fn.isImage() or fn.isMetaData()): # fn is None if xmippLib is the xmippLib ghost library from pwem.viewers import DataView DataView(path).show() else: _open_cmd(path) else: # This is probably one special reference, like sci-open:... that # can be interpreted with our handlers. tag = path.split(':', 1)[0] if ':' in path else None if tag in self.handlers: self.handlers[tag](path.split(':', 1)[-1]) else: print("Can't find %s" % path)
[docs] def updateMenu(self, e=None): state = 'normal' # if not xmippExists(self.selection): # state = 'disabled'#self.menu.entryconfig(1, background="green") self.menu.entryconfig(1, state=state)
[docs] def setReadOnly(self, value): state = tk.NORMAL if value: state = tk.DISABLED self.config(state=state)
[docs] def highlight(self, pattern, tag, start="1.0", end="end", regexp=False): """ Apply the given tag to all text that matches the given pattern If 'regexp' is set to True, pattern will be treated as a regular expression Taken from: http://stackoverflow.com/questions/3781670/tkinter-text-highlighting-in-python """ start = self.index(start) end = self.index(end) self.mark_set("matchStart", start) self.mark_set("matchEnd", start) self.mark_set("searchLimit", end) count = tk.IntVar() while True: index = self.search(pattern, "matchEnd", "searchLimit", count=count, regexp=regexp) if index == "": break self.mark_set("matchStart", index) self.mark_set("matchEnd", "%s+%sc" % (index, count.get())) self.tag_add(tag, "matchStart", "matchEnd")
[docs]def configureColorTags(text): """ Create tags in text (of type tk.Text) for all the supported colors. """ try: for color in ASCII_COLOR_2_TKINTER.values(): text.tag_config(color, foreground=color) return True except Exception as e: print("Colors still not available (%s)" % e) return False
[docs]class TaggedText(Text): """ Implement a Text that will recognize some basic tags *some_text* will display some_text in bold _some_text_ will display some_text in italic some_link or [[some_link][some_label]] will display some_link as hyperlink or some_label as hyperlink to some_link also colors are recognized if set option colors=True """ def __init__(self, master, colors=True, **opts): self.colors = colors Text.__init__(self, master, **opts) self.hm = HyperlinkManager(self)
[docs] def getDefaults(self): return {'bg': pw.Config.SCIPION_BG_COLOR, 'bd': 0}
# It used to have also 'font': gui.fontNormal but that stops # this file from running. Apparently there is no fontNormal in gui.
[docs] def configureTags(self): self.tag_config('normal', justify=tk.LEFT, font=gui.fontNormal) self.tag_config(HYPER_BOLD, justify=tk.LEFT, font=gui.fontBold) self.tag_config(HYPER_ITALIC, justify=tk.LEFT, font=gui.fontItalic) if self.colors: self.colors = configureColorTags(self)
# Color can be unavailable, so disable use of colors
[docs] @staticmethod def mailTo(email): webbrowser.open("mailto:" + email)
[docs] def matchHyperText(self, match, tag): """ Process when a match a found and store indexes inside string.""" self.insert(tk.END, self.line[self.lastIndex:match.start()]) g1 = match.group(tag) if tag == HYPER_BOLD or tag == HYPER_ITALIC: self.insert(tk.END, ' ' + g1, tag) elif tag == HYPER_LINK1: self.insert(tk.END, g1, self.hm.add(lambda: self.openLink(g1))) elif tag == HYPER_LINK2: label = match.group('link2_label') if g1.startswith('http'): self.insert(tk.END, label, self.hm.add(lambda: self.openLink(g1))) elif g1.startswith('mailto:'): self.insert(tk.END, label, self.hm.add(lambda: self.mailTo(g1))) else: self.insert(tk.END, label, self.hm.add(lambda: self.openPath(g1))) self.lastIndex = match.end() return g1
[docs] def addLine(self, line): self.line = line self.lastIndex = 0 if line is not None: parseHyperText(line, self.matchHyperText) Text.addLine(self, line[self.lastIndex:])
[docs]class OutputText(Text): """ Implement a Text that will show file content and handle console metacharacter for colored output """ def __init__(self, master, filename, colors=True, t_refresh=0, maxSize=400, **opts): """ colors flag indicate if try to parse color meta-characters t_refresh is the refresh time in seconds, 0 means no refresh """ self.filename = filename self.colors = colors self.t_refresh = t_refresh self.maxSize = maxSize self.refreshAlarm = None # Identifier returned by after() self.lineNo = 0 self.offset = 0 self.lastLine = '' Text.__init__(self, master, **opts) self.hm = HyperlinkManager(self) self.doRefresh()
[docs] def getDefaults(self): return {'bg': "black", 'fg': 'white', 'bd': 0, 'height': 30, 'width': 100}
# It used to have also 'font': gui.fontNormal but that stops this # file from running. Apparently there is no fontNormal in gui.
[docs] def configureTags(self): if self.colors: configureColorTags(self)
def _removeLastLine(self): line = int(self.index(tk.END).split('.')[0]) if line > 0: line -= 1 self.delete('%d.0' % line, tk.END)
[docs] def addLine(self, line): renderLine(line, self._addChunk, self.lineNo)
def _addChunk(self, txt, fmt=None): """ Add text txt to the widget, with format fmt. fmt can be a color (like 'red') or a link that looks like 'link:url'. """ if self.colors and fmt is not None: if fmt.startswith('link:'): fname = fmt.split(':', 1)[-1] self.insert(tk.END, txt, self.hm.add(lambda: openTextFileEditor(fname))) else: self.insert(tk.END, txt, fmt) else: self.insert(tk.END, txt) def _notifyLine(self, line): if '\r' in self.lastLine and '\r' in line: self._removeLastLine() self.addNewline() self.lastLine = line
[docs] def readFile(self, clear=False): self.setReadOnly(False) if clear: self.offset = 0 self.lineNo = 0 self.clear() if os.path.exists(self.filename): self.offset, self.lineNo = renderTextFile(self.filename, self._addChunk, offset=self.offset, lineNo=self.lineNo, maxSize=self.maxSize, notifyLine=self._notifyLine, errors='replace') # I'm cancelling this message. If file does not exist ... text is empty. # else: # self.insert(tk.END, "File '%s' doesn't exist" % self.filename) self.setReadOnly(True)
# self.goEnd()
[docs] def doRefresh(self): # First stop pending refreshes if self.refreshAlarm: self.after_cancel(self.refreshAlarm) self.refreshAlarm = None self.readFile() if self.t_refresh > 0: self.refreshAlarm = self.after(self.t_refresh*1000, self.doRefresh)
[docs]class TextFileViewer(tk.Frame): """ Implementation of a simple text file viewer """ # Not used? --> LabelBgColor = "white" def __init__(self, master, fileList=[], allowSearch=True, allowRefresh=True, allowOpen=False, font=None, maxSize=400, width=100, height=30): tk.Frame.__init__(self, master) self.searchList = None self.lastSearch = None self.refreshAlarm = None self._lastTabIndex = None self.fileList = [] # Files being visualized self.taList = [] # Text areas (OutputText, a scrollable TkText) self.fontDict = {} self._allowSearch = allowSearch self._allowRefresh = allowRefresh self._allowOpen = allowOpen self._font = font # allow a font to be passed as argument to be used self.maxSize = maxSize self.width = width self.height = height self.createWidgets(fileList) self.master = master self.addBinding()
[docs] def addFile(self, filename): self.fileList.append(filename) self._addFileTab(filename)
[docs] def clear(self): """ Remove all added files. """ self.fileList = [] for _ in self.taList: self.notebook.forget(0) self.taList = [] self._lastTabIndex = None
def _addFileTab(self, filename): tab = tk.Frame(self.notebook) tab.rowconfigure(0, weight=1) tab.columnconfigure(0, weight=1) kwargs = {'bg': 'black', 'fg': 'white'} if self._font is not None: kwargs['font'] = self._font t = OutputText(tab, filename, width=self.width, height=self.height, maxSize=self.maxSize, **kwargs) t.frame.grid(column=0, row=0, padx=5, pady=5, sticky='nsew') self.taList.append(t) tabText = " %s " % os.path.basename(filename) self.notebook.add(tab, text=tabText)
[docs] def createWidgets(self, fileList): # registerCommonFonts() self.columnconfigure(0, weight=1) self.rowconfigure(1, weight=1) # Create toolbar frame toolbarFrame = tk.Frame(self) toolbarFrame.grid(column=0, row=0, padx=5, sticky='new') gui.configureWeigths(toolbarFrame) # Add the search box right = tk.Frame(toolbarFrame) right.grid(column=1, row=0, sticky='ne') self.searchVar = tk.StringVar() if self._allowSearch: tk.Label(right, text='Search:').grid(row=0, column=3, padx=5) self.searchEntry = tk.Entry(right, textvariable=self.searchVar, font=self._font) self.searchEntry.grid(row=0, column=4, sticky='ew', padx=5) # self.searchEntry.bind('<Return>', self.findText) # self.searchEntry.bind('<KP_Enter>', self.findText) # # btn = IconButton(right, "Search", Icon.ACTION_SEARCH, # tooltip=Message.TOOLTIP_SEARCH, # command=self.findText, bg=None) # btn.grid(row=0, column=5, padx=(0, 5)) btn = IconButton(right, "Next", Icon.ACTION_FIND_NEXT, tooltip=Message.TOOLTIP_SEARCH_NEXT, command=self.findText, bg=None) btn.grid(row=0, column=5, padx=(0, 5)) btn = IconButton(right, "Previous", Icon.ACTION_FIND_PREVIOUS, tooltip=Message.TOOLTIP_SEARCH_PREVIOUS, command=self.findPrevText, bg=None) btn.grid(row=0, column=6, padx=(0, 5)) if self._allowRefresh: btn = IconButton(right, "Refresh", Icon.ACTION_REFRESH, tooltip=Message.TOOLTIP_REFRESH, command=self._onRefresh, bg=None) btn.grid(row=0, column=7, padx=(0, 5), pady=2) if self._allowOpen: btn = IconButton(right, "Open external", Icon.ACTION_REFERENCES, tooltip=Message.TOOLTIP_EXTERNAL, command=self._openExternal, bg=None) btn.grid(row=0, column=8, padx=(0, 5), pady=2) # Create tabs frame tabsFrame = tk.Frame(self) tabsFrame.grid(column=0, row=1, padx=5, pady=(0, 5), sticky="nsew") tabsFrame.columnconfigure(0, weight=1) tabsFrame.rowconfigure(0, weight=1) self.notebook = ttk.Notebook(tabsFrame) self.notebook.rowconfigure(0, weight=1) self.notebook.columnconfigure(0, weight=1) for f in fileList: self._addFileTab(f) self.notebook.grid(column=0, row=0, sticky='nsew', padx=5, pady=5) self.notebook.bind('<<NotebookTabChanged>>', self._tabChanged)
def _tabChanged(self, e=None): self._lastTabIndex = self.notebook.select() # reset the search self.lastSearch = None # Setting the focus, captures it when selecting protocols and # therefore "deleting" using keys or other future shortcut for the canvas # will not work. # self.searchEntry.focus_set()
[docs] def addBinding(self): shortcutDefinitions = [(lambda e: self.findText(), "Trigger the search", ['<Return>']), (lambda e: self.findText(), "Trigger the search", ['<KP_Enter>']), (lambda e: self.findText(matchCase=True), "Trigger a case sensitive search", ['<Shift-Return>']), (lambda e: self.findText(), "Move to the next highlighted item", ["<Down>", '<F3>']), (lambda e: self.findText(-1), "Move to the previous highlighted item", ["<Up>", '<Shift-F3>']), (lambda e: self.modifyFontSize(pw.Config.SCIPION_FONT_SIZE + 2), "Increase the font size",["<Control-KP_Add>"]), (lambda e: self.modifyFontSize(pw.Config.SCIPION_FONT_SIZE), "Increase the font size",["<Control-KP_Subtract>"]), ] tooltip = "Shortcuts:" for callback, help, keys in shortcutDefinitions: tooltip += "\n" + help + ": " for key in keys: self.searchEntry.bind(key, callback) tooltip += key # Add a tooltip ToolTip(self.searchEntry, tooltip, 800)
[docs] def getIndex(self): """ Return the index of the selected tab. """ selected = self.notebook.select() if selected: return self.notebook.index(selected) return -1
[docs] def setIndex(self, index): """ Select the tab with the given index. """ if index != -1: self.notebook.select(self.notebook.tabs()[index])
[docs] def selectedText(self): index = self.getIndex() if index != -1: return self.taList[index] return None
[docs] def changeFont(self, event=""): for font in self.fontDict.values(): gui.changeFontSize(font, event)
[docs] def refreshAll(self, clear=False, goEnd=False): """ Refresh all output textareas. """ for ta in self.taList: ta.readFile(clear) if goEnd: ta.goEnd() if self._lastTabIndex is not None: self.notebook.select(self._lastTabIndex)
def _onRefresh(self, e=None): """ Action triggered when the 'Refresh' icon is clicked. """ self.refreshAll(clear=False, goEnd=True)
[docs] def refreshOutput(self, e=None): if self.refreshAlarm: self.after_cancel(self.refreshAlarm) self.refreshAlarm = None text = self.selectedText() if text: text.readFile()
[docs] def changePosition(self, index): self.selectedText().see(index)
[docs] def findPrevText(self): self.findText(-1)
[docs] def modifyFontSize(self, newSize): text = self.selectedText() text['font']=(None, newSize)
[docs] def findText(self, direction=1, matchCase=0): text = self.selectedText() str = self.searchVar.get() if text: if str is None or str != self.lastSearch: self.buildSearchList(text, str, matchCase=matchCase) self.lastSearch = str else: self.nextSearchIndex(text, direction) self.searchEntry.focus_set()
[docs] def buildSearchList(self, text, str, matchCase=0): text.tag_remove('found', '1.0', tk.END) list = [] if str: idx = '1.0' while True: idx = text.search(str, idx, nocase=not matchCase, stopindex=tk.END) if not idx: break lastidx = '%s+%dc' % (idx, len(str)) text.tag_add('found', idx, lastidx) list.append((idx, lastidx)) idx = lastidx text.tag_config('found', foreground='white', background='blue') # Set class variables self.searchList = list self.currentIndex = -1 self.nextSearchIndex(text) # select first element
[docs] def nextSearchIndex(self, text, direction=1): # use direction=-1 to go backward text.tag_remove('found_current', '1.0', tk.END) if len(self.searchList) == 0: return self.currentIndex = (self.currentIndex + direction) % len(self.searchList) idx, lastidx = self.searchList[self.currentIndex] text.tag_config('found_current', foreground='yellow', background='red') text.tag_add('found_current', idx, lastidx) text.see(idx)
def _openExternal(self): """ Open a new window with an external viewer. """ if envVarOn('SCIPION_EXTERNAL_VIEWER'): if not self.taList: return openTextFileEditor(self.taList[max(self.getIndex(), 0)].filename) else: showTextFileViewer("File viewer", self.fileList, self.windows)
[docs]def openTextFile(filename): """ Open a text file with an external or default viewer. """ if envVarOn('SCIPION_EXTERNAL_VIEWER'): openTextFileEditor(filename) else: showTextFileViewer("File viewer", [filename])
[docs]def openTextFileEditor(filename, tkParent=None): try: _open_cmd(filename, tkParent) except: showTextFileViewer("File viewer", [filename])
[docs]def showTextFileViewer(title, filelist, parent=None, main=False): w = gui.Window(title, parent, minsize=(600, 400)) viewer = TextFileViewer(w.root, filelist, maxSize=-1, font=w.font) viewer.grid(row=0, column=0, sticky='news') gui.configureWeigths(w.root) w.show()
if __name__ == '__main__': root = tk.Tk() root.withdraw() root.title("View files") l = TextFileViewer(root, fileList=sys.argv[1:]) l.pack(side=tk.TOP, fill=tk.BOTH) gui.centerWindows(root) root.deiconify() root.mainloop()