Python ttk.combobox force post/open - python

I am trying to extend the ttk combobox class to allow autosuggestion. the code I have far works well, but I would like to get it to show the dropdown once some text has been entered without removing focus from the entry part of the widget.
The part I am struggling with is finding a way to force the dropdown, in the python docs I cannot find any mention of this, however in the tk docs I did find a post method I believe is supposed to do this, except it doesn't seem to be implemented in the python wrapper.
I also tried generating a down arrow key event once the autosuggest has taken place, however while this does show the dropdown it removes focus, and trying to set the focus after this event doesn't seem to work either (focus does not return)
Is anyone aware of a function I can use to achieve this?
The code I have is for python 3.3 using only standard libs:
class AutoCombobox(ttk.Combobox):
def __init__(self, parent, **options):
ttk.Combobox.__init__(self, parent, **options)
self.bind("<KeyRelease>", self.AutoComplete_1)
self.bind("<<ComboboxSelected>>", self.Cancel_Autocomplete)
self.bind("<Return>", self.Cancel_Autocomplete)
self.autoid = None
def Cancel_Autocomplete(self, event=None):
self.after_cancel(self.autoid)
def AutoComplete_1(self, event):
if self.autoid != None:
self.after_cancel(self.autoid)
if event.keysym in ["BackSpace", "Delete", "Return"]:
return
self.autoid = self.after(200, self.AutoComplete_2)
def AutoComplete_2(self):
data = self.get()
if data != "":
for entry in self["values"]:
match = True
try:
for index in range(0, len(data)):
if data[index] != entry[index]:
match = False
break
except IndexError:
match = False
if match == True:
self.set(entry)
self.selection_range(len(data), "end")
self.event_generate("<Down>",when="tail")
self.focus_set()
break
self.autoid = None

You do not need to inherit ttk.Combobox for this event; simply use event_generate to force the dropdown:
box = Combobox(...)
def callback(box):
box.event_generate('<Down>')

A workaround that achieves this UX using tooltips is demonstrated below. This is implemented using PySimpleGUI, but should be easily adaptable to "pure" tkinter.
from functools import partial
from typing import Callable, Any
from fuzzywuzzy import process, fuzz
import PySimpleGUI as sg
# SG: Helper functions:
def clear_combo_tooltip(*_, ui_handle: sg.Element, **__) -> None:
if tt := ui_handle.TooltipObject:
tt.hidetip()
ui_handle.TooltipObject = None
def show_combo_tooltip(ui_handle: sg.Element, tooltip: str) -> None:
ui_handle.set_tooltip(tooltip)
tt = ui_handle.TooltipObject
tt.y += 40
tt.showtip()
def symbol_text_updated(event_data: dict[str, Any], all_values: list[str], ui_handle: sg.Element) -> None:
new_text = event_data[ui_handle.key]
if new_text == '':
ui_handle.update(values=all_values)
return
matches = process.extractBests(new_text, all_values, scorer=fuzz.ratio, score_cutoff=40)
sym = [m[0] for m in matches]
ui_handle.update(new_text, values=sym)
# tk.call('ttk::combobox::Post', ui_handle.widget) # This opens the list of options, but takes focus
clear_combo_tooltip(ui_handle=ui_handle)
show_combo_tooltip(ui_handle=ui_handle, tooltip="\n".join(sym))
# Prepare data:
all_symbols = ["AAPL", "AMZN", "MSFT", "TSLA", "GOOGL", "BRK.B", "UNH", "JNJ", "XOM", "JPM", "META", "PG", "NVDA", "KO"]
# SG: Layout
sg.theme('DarkAmber')
layout = [
[
sg.Text('Symbol:'),
sg.Combo(all_symbols, enable_per_char_events=True, key='-SYMBOL-')
]
]
# SG: Window
window = sg.Window('Symbol data:', layout, finalize=True)
window['-SYMBOL-'].bind("<Key-Down>", "KeyDown")
# SG: Event loop
callbacks: dict[str: Callable] = {
'-SYMBOL-': partial(symbol_text_updated, all_values=all_symbols, ui_handle=window['-SYMBOL-']),
'-SYMBOL-KeyDown': partial(clear_combo_tooltip, ui_handle=window['-SYMBOL-']),
}
unhandled_event_callback = partial(lambda x: print(f"Unhandled event key: {event}. Values: {x}"))
while True:
event, values = window.read()
if event in (sg.WIN_CLOSED, 'Exit'):
break
callbacks.get(event, unhandled_event_callback)(values)
# SG: Cleanup
window.close()
This solution was inspired by this gist and this discussion.

Related

Binding the mouse left click to update a value in pandastable?

I'm using pandastable to create a list of items and edit it before converting it to a file. It's a very versatile module, but whenever I want to edit a cell, I have to press Enter to update the value, and I keep forgetting. I have read in the documentation that you can override the key bindings creating your own class. I managed to make the left click to do different things, but I can't see how to bind it to changing the value, as the "Return" key does. This is what I have tried:
import tkinter as tk
from pandastable import Table, Frame
import pandas as pd
screen = tk.Tk()
df = pd.read_csv("item_list.csv")
class MyTable(Table):
# based on original drawCellEntry() with required changes
def handle_left_click(self, event):
"""Respond to a single press"""
self.clearSelected()
self.allrows = False
#which row and column is the click inside?
rowclicked = self.get_row_clicked(event)
colclicked = self.get_col_clicked(event)
if colclicked == None:
return
self.focus_set()
if hasattr(self, 'cellentry'):
self.cellentry.update()
self.rowheader.redraw()
#ensure popup menus are removed if present
if hasattr(self, 'rightmenu'):
self.rightmenu.destroy()
if hasattr(self.tablecolheader, 'rightmenu'):
self.tablecolheader.rightmenu.destroy()
self.startrow = rowclicked
self.endrow = rowclicked
self.startcol = colclicked
self.endcol = colclicked
#reset multiple selection list
self.multiplerowlist=[]
self.multiplerowlist.append(rowclicked)
if 0 <= rowclicked < self.rows and 0 <= colclicked < self.cols:
self.setSelectedRow(rowclicked)
self.setSelectedCol(colclicked)
self.drawSelectedRect(self.currentrow, self.currentcol)
self.drawSelectedRow()
self.rowheader.drawSelectedRows(rowclicked)
self.tablecolheader.delete('rect')
if hasattr(self, 'cellentry'):
self.cellentry.update()
self.rowheader.redraw()
return
pt = MyTable(screen, dataframe=df, width=800)
pt.show()
screen.mainloop()
Any and all help will be much appreciated.

Dynamically add QTableView to dynamically created tab pages (QTabWidget)

I am trying to have a series of QTableView created at runtime and added to newly created pages of a multipage QTabWidget.
All seems to go fine, but the QTableView don't show up.
The QTabWidget gets zeroed (reset to no pages) and refurbished (...) flawlessly (at least it looks like so) depending on the selection of a combobox (and the dictionaries therein related).
I am also using a delegate callback to include a column of checkboxes to the QTableView (thanks to https://stackoverflow.com/a/50314085/7710452), which works fine stand alone.
Here is the code.
Main Window
EDIT
as recommended by eyllanesc, here is the standalone module (jump to the end of the post for details on the part I think is problematic):
"""
qt5 template
"""
import os
import sys
from PyQt5 import QtWidgets as qtw
from PyQt5 import QtCore as qtc
from PyQt5 import QtGui as qtg
from PyQt5 import uic
from configparser import ConfigParser, ExtendedInterpolation
from lib.SearchControllers import findGuis, get_controller_dict, show_critical, show_exception
import resources.resources
from lib.CheckBoxesDelegate import CheckBoxDelegate
myForm_2, baseClass = uic.loadUiType('./forms/setup.ui')
class MainWindow(baseClass):
def __init__(self, config_obj: ConfigParser,
config_name: str,
proj_name: str,
*args,
**kwargs):
super().__init__(*args, **kwargs)
self.ui = myForm_2()
self.ui.setupUi(self)
# your code begins here
self.setWindowTitle(proj_name + " Setup")
self.ui.logo_lbl.setPixmap(qtg.QPixmap(':/logo_Small.png'))
self.config_obj = config_obj
self.config_name = config_name
self.proj_filename = proj_name
self.proj_config = ConfigParser(interpolation=ExtendedInterpolation())
self.proj_config.read(proj_name)
self.guis_dict = {}
self.components = {}
self.cdp_signals = {}
self.root_path = self.config_obj['active']['controllers']
self.tableViews = []
self.tabs = []
self.iniControllersBox()
self.setActSignals()
self.load_bulk()
self.set_signals_table()
self.update_CurController_lbl()
self.update_ControllersTab() # here is where the action gets hot
# your code ends here
self.show() # here crashes if I passed the new tab to the instance of
# QTabView. otherwise it shows empty tabs
#########################################################
def load_bulk(self):
# get the list of running components into a dictionary
for i in self.list_controllers:
i_path = os.path.join(self.root_path, i)
print(i)
self.components[i] = get_controller_dict(i_path,
self.config_obj,
'Application.xml',
'Subcomponents/Subcomponent',
'Name',
'src')
for j in self.components[i]:
print(j)
signals_key = (i , j)
tgt = os.path.join(self.root_path, self.components[i][j])
self.cdp_signals[signals_key] = get_controller_dict(i_path,
self.config_obj,
self.components[i][j],
'Signals/Signal',
'Name',
'Type',
'Routing')
def set_signals_table(self):
self.ui.MonitoredDevicesTable.setHorizontalHeaderItem(0, qtw.QTableWidgetItem('GUI caption'))
self.ui.MonitoredDevicesTable.setHorizontalHeaderItem(1, qtw.QTableWidgetItem('Monitored Signal'))
def setActSignals(self):
self.ui.controllersBox.currentIndexChanged.connect(self.update_guis_list)
self.ui.controllersBox.currentIndexChanged.connect(self.update_CurController_lbl)
self.ui.controllersBox.currentIndexChanged.connect(self.update_ControllersTab)
def update_ControllersTab(self):
self.ui.componentsTab.clear() # this is the QTabWidget
self.tabs = []
self.tableViews = []
curr_controller = self.ui.controllersBox.currentText()
for i in self.components[curr_controller]:
if len(self.cdp_signals[curr_controller, i]) == 0:
continue
self.tabs.append(qtw.QWidget())
tabs_index = len(self.tabs) - 1
header_labels = ['', 'Signal', 'Type', 'Routing', 'Input']
model = qtg.QStandardItemModel(len(self.cdp_signals[curr_controller, i]), 5)
model.setHorizontalHeaderLabels(header_labels)
# in the next line I try to create a new QTableView passing
# the last tab as parameter, in the attempt to embed the QTableView
# into the QWidget Tab
self.tableViews.append(qtw.QTableView(self.tabs[tabs_index]))
tbw_Index = len(self.tableViews) - 1
self.tableViews[tbw_Index].setModel(model)
delegate = CheckBoxDelegate(None)
self.tableViews[tbw_Index].setItemDelegateForColumn(0, delegate)
rowCount = 0
for row in self.cdp_signals[curr_controller, i]:
for col in range(len(self.cdp_signals[curr_controller, i][row])):
index = model.index(rowCount, col, qtc.QModelIndex())
model.setData(index, self.cdp_signals[curr_controller, i][row][col])
try:
self.ui.componentsTab.addTab(self.tabs[tabs_index], i) # no problems, some controllers ask up to
except Exception as ex:
print(ex)
def update_CurController_lbl(self):
self.ui.active_controller_lbl.setText(self.ui.controllersBox.currentText())
def iniControllersBox(self):
self.list_controllers = [os.path.basename(f.path) for f in os.scandir(self.root_path) if f.is_dir() and str(
f.path).upper().endswith('NC')]
self.ui.controllersBox.addItems(self.list_controllers)
for i in range(self.ui.controllersBox.count()):
self.ui.controllersBox.setCurrentIndex(i)
newKey = self.ui.controllersBox.currentText()
cur_cntrlr = os.path.join(self.config_obj['active']['controllers'], self.ui.controllersBox.currentText())
self.guis_dict[newKey] = findGuis(cur_cntrlr, self.config_obj)
self.ui.controllersBox.setCurrentIndex(0)
self.update_guis_list()
def update_guis_list(self, index=0):
self.ui.GuisListBox.clear()
self.ui.GuisListBox.addItems(self.guis_dict[self.ui.controllersBox.currentText()])
if __name__ == '__main__':
config = ConfigParser()
config.read('./config.ini')
app = qtw.QApplication([sys.argv])
w = MainWindow(config, './config.ini',
'./test_setup_1.proj')
sys.exit(app.exec_())
and here the external to add the checkboxes column:
class CheckBoxDelegate(QtWidgets.QItemDelegate):
"""
A delegate that places a fully functioning QCheckBox cell of the column to which it's applied.
"""
def __init__(self, parent):
QtWidgets.QItemDelegate.__init__(self, parent)
def createEditor(self, parent, option, index):
"""
Important, otherwise an editor is created if the user clicks in this cell.
"""
return None
def paint(self, painter, option, index):
"""
Paint a checkbox without the label.
"""
self.drawCheck(painter, option, option.rect, QtCore.Qt.Unchecked if int(index.data()) == 0 else QtCore.Qt.Checked)
def editorEvent(self, event, model, option, index):
'''
Change the data in the model and the state of the checkbox
if the user presses the left mousebutton and this cell is editable. Otherwise do nothing.
'''
if not int(index.flags() & QtCore.Qt.ItemIsEditable) > 0:
return False
if event.type() == QtCore.QEvent.MouseButtonRelease and event.button() == QtCore.Qt.LeftButton:
# Change the checkbox-state
self.setModelData(None, model, index)
return True
if event.type() == QtCore.QEvent.MouseButtonPress or event.type() == QtCore.QEvent.MouseMove:
return False
return False
def setModelData (self, editor, model, index):
'''
The user wanted to change the old state in the opposite.
'''
model.setData(index, 1 if int(index.data()) == 0 else 0, QtCore.Qt.EditRole)
The 1st picture shows the layout in QTDesigner, the 2nd the result (emtpy tabs) when avoiding the crashing.
the QTabWidget has no problems in zeroing, or scale up, back to as many tab as I need, it's just that I have no clue on how to show the QTabview. My approach was to try to embed the QTabView in the tabpage passing it as parameter to the line creating the new QTabView.
Since I am using rather convoluted dictionaries, calling an XML parser to fill them up, not to mention the config files, I know even this version of my script is hardly reproduceable/runnable.
If someone had the patience of focusing on the update_ControllersTab method though, and tell me what I am doing wrong handling the QWidgets, it'd be great.
Again the basic idea is to clear the QTabWidget any time the user selects a different controller (combo box on the left):
self.ui.componentsTab.clear() # this is the QTabWidget
self.tabs = [] # list to hold QTabView QWidgets (pages) throughout the scope
self.tableViews = [] # list to hold QTabView(s) thorughout the scope
count how many tabs (pages) and hence embedded TabViews I need with the new controllers selected.
and then for each tab needed:
create a new tab (page)
self.tabs.append(qtw.QWidget())
tabs_index = len(self.tabs) - 1
create a new QTabView using a model:
header_labels = ['', 'Signal', 'Type', 'Routing', 'Input']
model = qtg.QStandardItemModel(len(self.cdp_signals[curr_controller, i]), 5)
model.setHorizontalHeaderLabels(header_labels)
self.tableViews.append(qtw.QTableView(self.tabs[tabs_index]))
tbw_Index = len(self.tableViews) - 1
self.tableViews[tbw_Index].setModel(model)
populate the TableView with data, and then finally add the tab widget (with the suppposedly embedded QTableView to the QTabWidget (the i argument is a string from my dbases Names:
self.ui.componentsTab.addTab(self.tabs[tabs_index], i)
This method is called also by the __init__ to initialize and apparently all goes error free, until the last 'init' statement:
`self.show()`
at which point the app crashes with:
Process finished with exit code 1073741845
on the other hand, if here instead of trying to embed the QTableView:
self.tableViews.append(qtw.QTableView(self.tabs[tabs_index]))
I omit the parameter, that is:
self.tableViews.append(qtw.QTableView())
the app doesn't crash anymore, but of course no QtableViews are shown, only empty tabpages:
As stupid as this may sound the problem is in... the delegate class that creates the checkboxes in the first column (see https://stackoverflow.com/a/50314085/7710452)
I commented out those two lines:
delegate = CheckBoxDelegate(None)
self.tableViews[tbw_Index].setItemDelegateForColumn(0, delegate)
and... bingo!
the CheckBoxDelegate works fine in the example shown in the post (a single QTableView form). I also tinkered around adding columns and rows, and moving the checkbox column back and forth with no problems. In that standalone. But as soon as I add the class and set the delegate, i am back at square zero, app crashing with:
Process finished with exit code 1073741845
so I am left with this problem now. Thnx to whomever read this.
Problem solved, see comment to post above.

QLineEdit unexpected behavior using QRegExpValidator

I'm using a QLineEdit widget to enter email addresses and I set up a QRegExpValidor to validate the entry.
The validator is working as far as preventing the input of characters not allowed in the QRegExp, but funny enough, it allows to enter intermediate input, either by pushing the enter key or by firing the "editingfinished" signal.
I verified the return of the validator which is correct (Intermediate or Acceptable).
Checking the PyQt5 documentation it seams that an intermediate state of the validator does not prevent the focus to change to another widget. Furthermore, it stops the firing of the editingFinished and returnedPressed signals so the user may enter wrong address as far as it marches partially the RegExp.
Ref: https://doc.qt.io/qt-5/qlineedit.html#acceptableInput-prop
I was able to solve my needs by removing the validator from the QLineEdit widget, and placing a method "checkValidator" linked to the cursorPositionChanged of the QLineEdit widget discarting the last character entered when nonacceptable, and setting the focus back to the QLineEdit when it validats intermmediate. It works just fine but when the the focus was reset, the others signals on other widgets were fired one at a time. Momentarily, I handle this by checking if the sender has focus at the start of the methods (see lookForFile)
Even though I could handle the issue, I appreciate very much anybody explaining me the proper way to use the RegExpValidator and or why resetting the focus fires the other signals out of the blue.
def setUI(self):
self.setWindowTitle("EMail Settings")
self.setModal(True)
rx = QRegExp(r"[a-z0-9_%]+#[a-z0-9%_]+\.[a-z0-9%_]{3,3}")
lblAddress = QLabel("EMail Address")
self.lineAddress = QLineEdit(self)
self.mailValidator = QRegExpValidator(rx, self.lineAddress)
#self.lineAddress.setValidator(self.mailValidator)
self.lineAddress.cursorPositionChanged.connect(self.checkValidator)
self.lineAddress.returnPressed.connect(self.checkValidator)
lblPassword = QLabel("Password")
self.linePwd = QLineEdit()
self.linePwd.setEchoMode(QLineEdit.PasswordEchoOnEdit)
lblOauth2 = QLabel("Oauth2 Token")
self.lineOauth = QLineEdit()
pushOauth = QPushButton("...")
pushOauth.setObjectName("token")
pushOauth.clicked.connect(self.lookForFile)
pushOauth.setFixedWidth(30)
#pyqtSlot()
def checkValidator(self):
self.lineAddress.blockSignals(True)
v = self.mailValidator.validate(self.lineAddress.text(), len(self.lineAddress.text()))
if v[0] == 0:
self.lineAddress.setText(self.lineAddress.text()[:-1])
elif v[0] == 1:
self.lineAddress.setFocus()
elif v[0] == 2:
pass
print("validates", v)
self.lineAddress.blockSignals(False)
#pyqtSlot()
def lookForFile(self):
try:
if not self.sender().hasFocus():
return
baseDir = "C"
obj = self.sender()
if obj.objectName() == "Draft":
capt = "Email Draft"
baseDir = os.getcwd() + "\\draft"
fileType = "Polo Management Email (*.pad)"
dialog = QFileDialog(self, directory=os.getcwd())
dialog.setFileMode(QFileDialog.Directory)
res = dialog.getExistingDirectory()
elif obj.objectName() == "token":
capt = "Gmail Outh2 token File"
fileType = "Gmail token Files (*.json)"
baseDir = self.lineOauth.text()
res = QFileDialog.getOpenFileName(self, caption=capt, directory=baseDir, filter=fileType)[0]
fileName = res
if obj.objectName() == "Draft":
self.lineDraft.setText(fileName)
elif obj.objectName() == "tokenFile":
self.lineOauth.setText(fileName)
except Exception as err:
print("settings: lookForFile", err.args)
Hope to answer #eyllanesc and Qmusicmante request with this minimal reproducible example. I change the regex for a simple one allowing for a string of lower case a-z follow by a dot and three more lowercase characters.
What I intend is the validator not allowing the user to enter a wrong input. The example allows for "xxxzb.ods" but also allows "xxxzb" or "xxxzb.o" for instance.
In short, not allowing the user to enter a wrong input.
This is my minimal reproducible example:
class CheckValidator(QDialog):
def __init__(self, parent=None):
super().__init__()
self.parent = parent
self.setUI()
def setUI(self):
self.setWindowTitle("EMail Settings")
self.setModal(True)
rx = QRegExp(r"[a-z]+\.[a-z]{3}")
lblAddress = QLabel("Check Line")
self.lineAddress = QLineEdit()
self.mailValidator = QRegExpValidator(rx, self.lineAddress)
self.lineAddress.setValidator(self.mailValidator)
self.lineAddress.cursorPositionChanged[int, int].connect(lambda
oldPos, newPos: self.printValidator(newPos))
lblCheck = QLabel("Check")
lineCheck = QLineEdit()
formLayout = QFormLayout()
formLayout.addRow(lblAddress, self.lineAddress)
formLayout.addRow(lblCheck, lineCheck)
self.setLayout(formLayout)
#pyqtSlot(int)
def printValidator(self, pos):
print(self.mailValidator.validate(self.lineAddress.text(), pos))
if __name__ == '__main__':
app = QApplication(sys.argv)
tst = CheckValidator()
tst.show()
app.exec()
I found a solution and I post it here for such a case it may help somebody else.
First I remove the QRegExpValidator from the QLineEdit widget. The reason being is that QLineEdit only fires editingFinished (and we'll need it ) only when QRegExpValidator returns QValidator.Acceptable while the validator is present.
Then we set a method fired by the 'cursorPositionchanged' signal of QlineEdit widget. On this method, using the QRegExpValidator we determine if the last character entered is a valid one. If not we remove it.
Finally, I set a method fired by the 'editingFinished' signal using the RegEx exactMatch function to determine if the entry is valid. If it is not, we give the user the option to clear the entry or to return to the widget to continue entering data. The regex used is for testing purposes only, for further information about email validation check #Musicamante comments.
This is the code involved:
def setUI(self):
................
................
rx = QRegExp(r"[a-z0-9_%]+#[a-z0-9%_]+\.[a-z0-9%_]{3,3}")
lblAddress = QLabel("EMail Address")
self.lineAddress = QLineEdit(self)
self.mailValidator = QRegExpValidator(rx, self.lineAddress)
self.lineAddress.cursorPositionChanged[int, int].connect(lambda oldPos,
newPos: self.checkValidator(newPos))
self.lineAddress.editingFinished.connect(lambda : self.checkRegExp(rx))
#pyqtSlot(int)
def checkValidator(self, pos):
v = self.mailValidator.validate(self.lineAddress.text(), pos ))
if v[0] == 0:
self.lineAddress.setText(self.lineAddress.text()[:-1])
#pyqtSlot(QRegExp)
def checkRegExp(self, rx):
if not rx.exactMatch(self.lineAddress.text()) and self.lineAddress.text():
if QMessageBox.question(self, "Leave the Address field",
"The string entered is not a valid email address! \n"
"Do you want to clear the field?", QMessageBox.Yes|QMessageBox.No) ==
QMessageBox.Yes:
self.lineAddress.clear()
else:
self.lineAddress.setFocus()

Tkinter window event <Visibility>

I tried to get an event if the windows visibility is changed.
I found out that there is an event called "Visibility".
The Operating System is Windows 64bit.
So I implemented in the following way:
root.bind('<Visibility>', visibilityChanged)
But I always got the state "VisibilityUnobscured" no matter if there is a window over it or not. What is the normal behaviour of this event? How can I implement a feature like that?
Example Prog:
import tkinter as tk
class GUI:
def __init__(self, master):
self.master = master
master.title("Test GUI")
self.master.bind('<Visibility>', self.visibilityChanged)
self.label = tk.Label(master, text="GUI")
self.label.pack()
self.close_button = tk.Button(master, text="Close", command=master.quit)
self.close_button.pack()
def visibilityChanged(self, event):
if (str(event.type) == "Visibility"):
print(event.state)
root = tk.Tk()
my_gui = GUI(root)
root.mainloop()
What is the normal behaviour of this event?
It's well described in the docs:The X server generates VisibilityNotify event whenever the visibility changes state and for any window.
How can I implement a feature like that?
It depends on how far you are going to go in your wishes, since this isn't a trivial task. Thus, don't treat that answer as a complete solution, but as a problem overview and a set of suggestions.
The event problem
Windows OS uses a message-passing model - the system communicates with your application window via messages, where each message is a numeric code that designates a particular event. Application window has an associated window procedure — a function that processes (responds or ignores) all messages sent.
The most generic solution is to set a hook to catch certain events/messages and it's possible either via SetWindowsHookEx or pyHook.
The main problem is to get event, because the Windows WM has no such message as VisibilityNotify. As I said in comment section - one option, on which we can rely, is the z-order
(there's possibility to check Visibility of the window, whenever this window changes it's position in z-order).Therefore our target message is either WM_WINDOWPOSCHANGING or WM_WINDOWPOSCHANGED.
A naive implementation:
import ctypes
import ctypes.wintypes as wintypes
import tkinter as tk
class CWPRETSTRUCT(ctypes.Structure):
''' a class to represent CWPRETSTRUCT structure
https://msdn.microsoft.com/en-us/library/windows/desktop/ms644963(v=vs.85).aspx '''
_fields_ = [('lResult', wintypes.LPARAM),
('lParam', wintypes.LPARAM),
('wParam', wintypes.WPARAM),
('message', wintypes.UINT),
('hwnd', wintypes.HWND)]
class WINDOWPOS(ctypes.Structure):
''' a class to represent WINDOWPOS structure
https://msdn.microsoft.com/en-gb/library/windows/desktop/ms632612(v=vs.85).aspx '''
_fields_ = [('hwnd', wintypes.HWND),
('hwndInsertAfter', wintypes.HWND),
('x', wintypes.INT),
('y', wintypes.INT),
('cx', wintypes.INT),
('cy', wintypes.INT),
('flags', wintypes.UINT)]
class App(tk.Tk):
''' generic tk app with win api interaction '''
wm_windowposschanged = 71
wh_callwndprocret = 12
swp_noownerzorder = 512
set_hook = ctypes.windll.user32.SetWindowsHookExW
call_next_hook = ctypes.windll.user32.CallNextHookEx
un_hook = ctypes.windll.user32.UnhookWindowsHookEx
get_thread = ctypes.windll.kernel32.GetCurrentThreadId
get_error = ctypes.windll.kernel32.GetLastError
get_parent = ctypes.windll.user32.GetParent
wnd_ret_proc = ctypes.WINFUNCTYPE(ctypes.c_long, wintypes.INT, wintypes.WPARAM, wintypes.LPARAM)
def __init__(self):
''' generic __init__ '''
super().__init__()
self.minsize(350, 200)
self.hook = self.setup_hook()
self.protocol('WM_DELETE_WINDOW', self.on_closing)
def setup_hook(self):
''' setting up the hook '''
thread = self.get_thread()
hook = self.set_hook(self.wh_callwndprocret, self.call_wnd_ret_proc, wintypes.HINSTANCE(0), thread)
if not hook:
raise ctypes.WinError(self.get_error())
return hook
def on_closing(self):
''' releasing the hook '''
if self.hook:
self.un_hook(self.hook)
self.destroy()
#staticmethod
#wnd_ret_proc
def call_wnd_ret_proc(nCode, wParam, lParam):
''' an implementation of the CallWndRetProc callback
https://msdn.microsoft.com/en-us/library/windows/desktop/ms644976(v=vs.85).aspx'''
# get a message
msg = ctypes.cast(lParam, ctypes.POINTER(CWPRETSTRUCT)).contents
if msg.message == App.wm_windowposschanged and msg.hwnd == App.get_parent(app.winfo_id()):
# if message, which belongs to owner hwnd, is signaling that windows position is changed - check z-order
wnd_pos = ctypes.cast(msg.lParam, ctypes.POINTER(WINDOWPOS)).contents
print('z-order changed: %r' % ((wnd_pos.flags & App.swp_noownerzorder) != App.swp_noownerzorder))
return App.call_next_hook(None, nCode, wParam, lParam)
app = App()
app.mainloop()
As you can see, this implementation has a similar behavior as a "broken" Visibility event.
This problem stems from the fact, that you can catch only thread-specified messages, hence application doesn't know about changes in the stack. It's just my assumptions, but I think that the cause of the broken Visibility is same.
Of course, we can setup a global hook for all messages, regardless a thread, but this approach requires a DLL injection, which is an another story for sure.
The visibility problem
It's not a problem to determine obscuration of the window, since we can rely on Graphical Device Interface.
The logic is simple:
Represent window (and each visible window, which is higher in the z-order) as a rectangle.
Subtract from main rectangle each rectangle and store result.
If final geometrical subtraction is:
... an empty rectangle — return 'VisibilityFullyObscured'
... a set of rectangles — return 'VisibilityPartiallyObscured'
... a single rectangle:
if geometrical difference between result and original rectangle is:
... an empty rectangle — return 'VisibilityUnobscured'
... a single rectangle — return 'VisibilityPartiallyObscured'
A naive implementation (with self-scheduled loop):
import ctypes
import ctypes.wintypes as wintypes
import tkinter as tk
class App(tk.Tk):
''' generic tk app with win api interaction '''
enum_windows = ctypes.windll.user32.EnumWindows
is_window_visible = ctypes.windll.user32.IsWindowVisible
get_window_rect = ctypes.windll.user32.GetWindowRect
create_rect_rgn = ctypes.windll.gdi32.CreateRectRgn
combine_rgn = ctypes.windll.gdi32.CombineRgn
del_rgn = ctypes.windll.gdi32.DeleteObject
get_parent = ctypes.windll.user32.GetParent
enum_windows_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
def __init__(self):
''' generic __init__ '''
super().__init__()
self.minsize(350, 200)
self.status_label = tk.Label(self)
self.status_label.pack()
self.after(100, self.continuous_check)
self.state = ''
def continuous_check(self):
''' continuous (self-scheduled) check '''
state = self.determine_obscuration()
if self.state != state:
# mimic the event - fire only when state changes
print(state)
self.status_label.config(text=state)
self.state = state
self.after(100, self.continuous_check)
def enumerate_higher_windows(self, self_hwnd):
''' enumerate window, which has a higher position in z-order '''
#self.enum_windows_proc
def enum_func(hwnd, lParam):
''' clojure-callback for enumeration '''
rect = wintypes.RECT()
if hwnd == lParam:
# stop enumeration if hwnd is equal to lParam (self_hwnd)
return False
else:
# continue enumeration
if self.is_window_visible(hwnd):
self.get_window_rect(hwnd, ctypes.byref(rect))
rgn = self.create_rect_rgn(rect.left, rect.top, rect.right, rect.bottom)
# append region
rgns.append(rgn)
return True
rgns = []
self.enum_windows(enum_func, self_hwnd)
return rgns
def determine_obscuration(self):
''' determine obscuration via CombineRgn '''
hwnd = self.get_parent(self.winfo_id())
results = {1: 'VisibilityFullyObscured', 2: 'VisibilityUnobscured', 3: 'VisibilityPartiallyObscured'}
rgns = self.enumerate_higher_windows(hwnd)
result = 2
if len(rgns):
rect = wintypes.RECT()
self.get_window_rect(hwnd, ctypes.byref(rect))
# region of tk-window
reference_rgn = self.create_rect_rgn(rect.left, rect.top, rect.right, rect.bottom)
# temp region for storing diff and xor rgn-results
rgn = self.create_rect_rgn(0, 0, 0, 0)
# iterate over stored results
for _ in range(len(rgns)):
_rgn = rgn if _ != 0 else reference_rgn
result = self.combine_rgn(rgn, _rgn, rgns[_], 4)
self.del_rgn(rgns[_])
if result != 2:
# if result isn't a single rectangle
# (NULLREGION - 'VisibilityFullyObscured' or COMPLEXREGION - 'VisibilityPartiallyObscured')
pass
elif self.combine_rgn(rgn, reference_rgn, rgn, 3) == 1:
# if result of XOR is NULLREGION - 'VisibilityUnobscured'
result = 2
else:
# 'VisibilityPartiallyObscured'
result = 3
# clear up regions to prevent memory leaking
self.del_rgn(rgn)
self.del_rgn(reference_rgn)
return results[result]
app = App()
app.mainloop()
Unfortunately, this approach is far from a working solution, but it's tweakable in a perspective.

How to speed up scrolling responsiveness when displaying lots of text

I am trying to create a Python script to highlight specific patterns in a .txt file. To do this, I have altered a script which used Tkinter to highlight a given set of data. However, the files I tend to get it to process are around 10000 lines, which results in slow scrolling as I think it renders everything - whether it is on the screen or not (correct me if I'm wrong). Is it possible to alter my code such that it renders the output in a more efficient way? I have tried searching for a means to do this, but have not found anything myself.
My code is as follows:
from Tkinter import *
class FullScreenApp(object):
def __init__(self, master, **kwargs):
self.master=master
pad=3
self._geom='200x200+0+0'
master.geometry("{0}x{1}+0+0".format(
master.winfo_screenwidth()-pad, master.winfo_screenheight()-pad))
master.bind('<Escape>',self.toggle_geom)
def toggle_geom(self,event):
geom=self.master.winfo_geometry()
print(geom,self._geom)
self.master.geometry(self._geom)
self._geom=geom
root = Tk()
app = FullScreenApp(root)
t = Text(root)
t.pack()
#Import file
with open('data.txt') as f:
for line in f:
t.insert(END, line)
#Search terms - Leave blank if not required
search_term0 = '0xCAFE'
search_term1 = '0x0011'
search_term2 = '0x961E'
search_term3 = '0x0000'
search_term4 = ''
#Assigns highlighted colours for terms not blank
t.tag_config(search_term0, background='red')
if search_term1 != '':
t.tag_config(search_term1, background='red')
if search_term2 != '':
t.tag_config(search_term2, background='red')
if search_term3 != '':
t.tag_config(search_term3, background='red')
if search_term4 != '':
t.tag_config(search_term4, background='red')
#Define search
#Requires text widget, the keyword, and a tag
def search(text_widget, keyword, tag):
pos = '1.0'
while True:
idx = text_widget.search(keyword, pos, END)
if not idx:
break
pos = '{}+{}c'.format(idx, len(keyword))
text_widget.tag_add(tag, idx, pos)
#Search for terms that are not blank
search(t, search_term0, search_term0)
if search_term1 != '':
search(t, search_term1, search_term1)
if search_term2 != '':
search(t, search_term2, search_term2)
if search_term3 != '':
search(t, search_term3, search_term3)
if search_term4 != '':
search(t, search_term4, search_term3)
root.mainloop()
An example of the data in a file is given in the following link: here
Many thanks for your time, it is really appreciated.
Assuming MCVE is the following:
import tkinter as tk
def create_text(text_len):
_text = list()
for _ in range(text_len):
_text.append("{}\n".format(_))
_text = "".join(_text)
return _text
if __name__ == '__main__':
root = tk.Tk()
txt = tk.Text(root)
txt.text = create_text(10000)
txt.insert('end', txt.text)
txt.pack()
root.mainloop()
Analysis
Based on this I don't think it is a rendering issue. I think it's an issue with having a fixed rate of registering <KeyPress> events. Meaning that the number of events registered per second is fixed, even though the hardware may be capable of registering at a faster rate. A similar regulation should be true also for the mouse-scroll event.
Solutions for rendering
Perhaps slicing text for a buffer proportion of txt['height'] would help. But isn't that how Tk supposed to be rendering anyway?
Solutions for rendering unrelated issue
If a step would be defined as the cursor's movement to the previous or the next line, for every registered event of Up or Down; then scrolling_speed = step * event_register_frequency.
By increasing the step size
An easy workaround would be to simply increase the step size, as in increasing the number of lines to jump, for each registration of the key bind.
But there's already such default behavior, assuming the page length > 1 line, Page Up or Page Down has a step size of a page. Which makes the scrolling speed increase, even though the event registration rate remains the same.
Alternatively, a new event handler with a greater step size may be defined to call multiple cursor movements for each registration of Up and Down, such as:
import tkinter as tk
def create_text(text_len):
_text = list()
for _ in range(text_len):
_text.append("{}\n".format(_))
_text = "".join(_text)
return _text
def step(event):
if txt._step_size != 1:
_no_of_lines_to_jump = txt._step_size
if event.keysym == 'Up':
_no_of_lines_to_jump *= -1
_position = root.tk.call('tk::TextUpDownLine', txt, _no_of_lines_to_jump)
root.tk.call('tk::TextSetCursor', txt, _position)
return "break"
if __name__ == '__main__':
root = tk.Tk()
txt = tk.Text(root)
txt.text = create_text(10000)
txt.insert('end', txt.text)
txt._step_size = 12
txt.bind("<Up>", step)
txt.bind("<Down>", step)
txt.pack()
root.mainloop()
By mimicking keypress event registry rate increase:
As mentioned in here actually modifying keypress registry rate is out of scope of Tk. Instead, it can be mimicked:
import tkinter as tk
def create_text(text_len):
_text = list()
for _ in range(text_len):
_text.append("{}\n".format(_))
_text = "".join(_text)
return _text
def step_up(*event):
_position = root.tk.call('tk::TextUpDownLine', txt, -1)
root.tk.call('tk::TextSetCursor', txt, _position)
if txt._repeat_on:
root.after(txt._repeat_freq, step_up)
return "break"
def step_down(*event):
_position = root.tk.call('tk::TextUpDownLine', txt, 1)
root.tk.call('tk::TextSetCursor', txt, _position)
if txt._repeat_on:
root.after(txt._repeat_freq, step_down)
return "break"
def stop(*event):
if txt._repeat_on:
txt._repeat_on = False
root.after(txt._repeat_freq + 1, stop)
else:
txt._repeat_on = True
if __name__ == '__main__':
root = tk.Tk()
txt = tk.Text(root)
txt.text = create_text(10000)
txt.insert('end', txt.text)
txt._repeat_freq = 100
txt._repeat_on = True
txt.bind("<KeyPress-Up>", step_up)
txt.bind("<KeyRelease-Up>", stop)
txt.bind("<KeyPress-Down>", step_down)
txt.bind("<KeyRelease-Down>", stop)
txt.pack()
root.mainloop()
By both increasing step-size and mimicking registry rate increase
import tkinter as tk
def create_text(text_len):
_text = list()
for _ in range(text_len):
_text.append("{}\n".format(_))
_text = "".join(_text)
return _text
def step_up(*event):
_no_of_lines_to_jump = -txt._step_size
_position = root.tk.call('tk::TextUpDownLine', txt, _no_of_lines_to_jump)
root.tk.call('tk::TextSetCursor', txt, _position)
if txt._repeat_on:
root.after(txt._repeat_freq, step_up)
return "break"
def step_down(*event):
_no_of_lines_to_jump = txt._step_size
_position = root.tk.call('tk::TextUpDownLine', txt, _no_of_lines_to_jump)
root.tk.call('tk::TextSetCursor', txt, _position)
if txt._repeat_on:
root.after(txt._repeat_freq, step_down)
return "break"
def stop(*event):
if txt._repeat_on:
txt._repeat_on = False
root.after(txt._repeat_freq + 1, stop)
else:
txt._repeat_on = True
if __name__ == '__main__':
root = tk.Tk()
txt = tk.Text(root)
txt.text = create_text(10000)
txt.insert('end', txt.text)
txt._step_size = 1
txt._repeat_freq = 100
txt._repeat_on = True
txt.bind("<KeyPress-Up>", step_up)
txt.bind("<KeyRelease-Up>", stop)
txt.bind("<KeyPress-Down>", step_down)
txt.bind("<KeyRelease-Down>", stop)
txt.pack()
root.mainloop()
So this is solved by something called multi threading. A computer can do multiple tasks at once, otherwise, your web experience wouldn't be the same. Here is a simple function that demonstrates mulit-threading
from threading import Thread
def execOnDifferentThread(funct=print, params=("hello world",)):
t = Thread(target=funct, args=params)
t.start()
Now note, this might not be the best example, but all you have to do to run a function in parallel now, is execOnDifferentThread(funct=A, params=B) where A is a function name, and B is a tuple of arguments that will be passed on to your function. Now, I don't want to write your code for you, but using this function, you can multi-thread certain parts of your code to make it faster. If you are truly stuck, just comment where and ill help. But please try on your own first, now that you have the power of multi-threading on your hands

Categories

Resources