I want to get the cursor position (line and column) of the insertion point of a Tkinter.Text, but for the specific situation below.
PROBLEM: My text editor project requires a custom undo/redo for Tkinter.Text. I put in the same string for both Test One and Test Two below, but undo does not act consistently due to a inconsistent column variable in KeyRelease event handler given by Tkinter. The problem seems to be that I type too fast for second test which produces a bad column value. Can you help me find the problem?
TWO TEST PROCESS TO REPRODUCE THE ERROR:
TEST ONE
Type this string slowly: 'one two three'
Press F1 to see each word undo.
Result: Works fine. (For me atleast. Ephasis: type slowly.)
TEST TWO
Type the same string as fast as you can: 'one two three'
Press F1 to see each word undo.
Result: Gets the wrong column and does not undo properly. (Restart script and repeat this step if you don't see the error at first, it sometimes works fine with fast typing. I usually get it with 3 to 4 tries at the most.)
QUESTION: Is this a bug in Tkinter, or am I not understanding something specific within Tkinter that would produce consistent columns for my undo/redo records?
from Tkinter import *
class TextView(Text):
def __init__(self, root):
Text.__init__(self, root)
self.history = History(self)
self.bind("<KeyRelease>", self.keyRelease)
# used to capture a char at a time in keyRelease. If space char is pressed it creates a Word object and adds it to undo/redo history.
self.word = ""
def keyRelease(self, event):
if event.keysym == "space":
self.word += " "
self.makeWordRecord()
else:
self.word += event.char
def makeWordRecord(self, ):
if len(self.word):
index = self.index(INSERT)
wordEvent = Word(index, self.word)
self.history.addEvent(wordEvent)
self.word = ""
def undo(self, event):
self.makeWordRecord()
self.history.undo()
def redo(self, event):
self.history.redo()
class History(object):
def __init__(self, text):
self.text = text
self.events = []
self.index = -1
# create blank document record, line one, column one, no text
self.addEvent(Word("1.0", ""))
def addEvent(self, event):
if self.index +1 < len(self.events):
self.events = self.events[:self.index +1]
self.events.append(event)
self.index +=1
def undo(self):
if self.index > 0:
self.events[self.index].undo(self.text)
self.index -=1
def redo(self):
if self.index +1 < len(self.events):
self.index +=1
self.events[self.index].redo(self.text)
class Word(object):
def __init__(self, index, word):
self.index = index
self.word = word
def undo(self, text):
line = self.index.split(".")[0]
column = int(self.index.split(".")[-1])
startIndex = line + "." + str(column - len(self.word))
endIndex = line + "." + str(int(column))
text.delete(startIndex, endIndex)
def redo(self, text):
line = self.index.split(".")[0]
column = int(self.index.split(".")[-1])
startIndex = line + "." + str(column - len(self.word))
text.insert(startIndex, self.word)
if __name__ == "__main__":
root = Tk()
root.geometry("400x200+0+0")
textView = TextView(root)
textView.pack()
root.bind("<F1>", textView.undo)
root.bind("<F2>", textView.redo)
root.mainloop()
I finally figured out what was going on and has nothing to do with Tkinter, but all Toolkits. I can now honestly say that I can add something to Best Programming Practices and :
Do Not Process Key Events by Binding to a Key Release Method, Instead Process Keys with a Key Press Method
Why?
It's not really a programming issue, it's a hardware issue. When a key is pressed, the physical key goes down. There is a spring that pushes the key back up. If that spring or anything about your keyboard causes a key to be even 200ths of second slower, typing even 60 words a minute may cause keys that were typed in one order to come back in another order. This may happen because a spring may be slightly thicker, stiffer, over used, or even sticky mocha cause more resistance.
Capitalization can be affected as well. Pressing the shift key and another key to get an upper case must be simultaneously pressed down. But if you process keys on key release, it is possible the shift key springs up faster than the character key you are capitalizing, which will result in a lower case. This also allows characters to be inverted. If you check on positions of a character when it's typed, you can get the wrong position due to this as well.
Stick with Key Press.
Related
I've been working on a Tkinter (Python) project that displays a list of strings using the Text widget recently, but I ran into an issue I couldn't manage to solve :
On startup, I want the first line to be highlighted, and when I click on up/down arrows, the highlight goes up/down, as a selection bar.
I succeed to do that, but the problem is that the highlight only appears when arrows are pressed, and when they are released, it disappear. I'd like it to stay even when I'm not pressing any key.
Here is my code :
class Ui:
def __init__(self):
# the list I want to display in Text
self.repos = repos
# here is the entry keys are bind to
self.entry = Entry(root)
self.entry.pack()
self.bind('<Up>', lambda i: self.changeIndex(-1))
self.bind('<Down>', lambda i: self.changeIndex(1))
# here is the Text widget
self.lists = Text(root, state=NORMAL)
self.lists.pack()
# inits Text value
for i in self.repos:
self.lists.insert('insert', i + '\n')
self.lists['state'] = DISABLED
# variable I use to navigate with highlight
self.index = 0
self.lists.tag_add('curr', str(self.index) + '.0', str(self.index + 1) + '.0') # added + '.0' to make it look '0.0' instead of '0'
self.lists.tag_config('curr', background='#70fffa', background='#000000')
self.root.mainloop()
def changeIndex(self, n):
# error gestion (if < 0 or > len(repos), return)
self.lists.tag_delete('curr')
self.lists.tag_add('curr', str(self.index) + '.0', str(self.index + 1) + '.0')
self.index = self.index + n
# to make it scroll if cannot see :
self.lists.see(str(self.index) + '.0')
I haven't seen any similar problem on Stack, so I asked, but do not hesitate to tell me if it is a duplicate.
Do you guys could help me please ? Thanks !
EDIT: Here is the full code if you want to give it a try : https://github.com/EvanKoe/stack_tkinter.git
EDIT : I added the main.py file (the """backend""" file that calls ui.py) to the demo repository. This way, you'll be able to run the project (be careful, there are "YOUR TOKEN" and "YOUR ORGANIZATION" strings in main.py you'll have to modify with your own token/organization. I couldn't push mine or Github would've asked me to delete my token)
The following code should do what you expect. Explanation below code
from tkinter import *
repos = ["one","two","three","four"]
class Ui:
def __init__(self, parent):
# the list I want to display in Text
self.repos = repos
# here is the entry keys are bind to
self.entry = Entry(parent)
self.entry.pack()
self.entry.bind('<Up>', lambda i: self.changeIndex(-1))
self.entry.bind('<Down>', lambda i: self.changeIndex(1))
# here is the Text widget
self.lists = Text(parent, state=NORMAL)
self.lists.pack()
# inits Text value
for i in self.repos:
self.lists.insert('insert', i + '\n')
self.lists['state'] = DISABLED
# variable I use to navigate with highlight
self.index = 1
self.lists.tag_add('curr', str(self.index) + '.0', str(self.index + 1) + '.0') # added + '.0' to make it look '0.0' instead of '0'
self.lists.tag_config('curr', background='#70fffa', foreground='#000000')
def changeIndex(self, n):
print(f"Moving {n} to {self.index}")
self.index = self.index + n
self.index = min(max(self.index,0),len(self.repos))
self.lists.tag_delete('curr')
self.lists.tag_config('curr', background='#70fffa', foreground='#000000')
self.lists.tag_add('curr', str(self.index) + '.0', str(self.index + 1) + '.0')
# to make it scroll if cannot see :
self.lists.see(str(self.index) + '.0')
root = Tk()
ui = Ui(root)
root.mainloop()
Few changes made to your code
Changed the Ui function to accept the parent tk object as a parameter
Changed self.index to be initialised to 1 rather than 0 since the first line on a text box is 1 not 0
Bound the Up/Down keys to the entry box. Not sure why this is what you are going for but this seems to be what your comments indicate
Added some checking code to limit the index value between 1 and len(repos)
Re-created the tag style each time it is set since you delete the tag (this is why it wasn't showing)
I'd suggest that you look to bind the up/down button press to the text box rather than the entry box. Seems a bit strange to have to select a blank entry box to scroll up and down in a list.
Also, why aren't you just using the build in Tkinter list widget?
I finally managed to solve the problem, and it was due to my event bindings. I made the decision (to improve the UX) to bind up/down arrows on the top Entry instead of binding em on the Text widget. I bind 4 events :
Up arrow => move highlight up,
Down arrow => move highlight down,
Return key => calls get_name(), a function that returns the selected option,
Any other Key => calls repo_filter(), a function that updates the displayed options in the Text widget, according to what has been typed in the Entry.
The problem was that pressing the up/down arrow was triggering "up/down key" event AND "any other key" event, so the highlight was removed since the Text value was refreshed.
To solve this problem, I just had to verify that the pressed key was neither up nor down arrow in the "any other key" event callback :
def repo_filter(evt):
if evt.keysym == 'Up' or evt.keysym == 'Down': # verify that pressed key
return # isn't '<Down>' or '<Up>'
# filter Text widget
Also, I am sorry I didn't give you all the code at the beginning, because, indeed you couldn't guess about those event bindings.
Thanks to everyone who tried to help me !
On the following strip-down version of my program, I want to simulate an exchange where the user and the computer will act following a random sequence.
Here the variable row contains the sequence order. A value of 0 means the program is waiting for a user input (method val0). A value of 1 means there should be an automatic process (method val1).
It seems to work at the beginning when alternating 0 and 1 but as soon as we are waiting for two successive automatic calls, it goes out of whack.
I tried using the after method but I can't see how and where to insert it.
A while loop might do the trick on this example but the end program is more complex, with sequence interruption, reevaluation of the sequence and so on. So I don't know if it would still apply then.
from tkinter import *
class Application:
def __init__(self,master = None):
self.master = master
Label(master,text='press next').grid()
Button(master,text='Next',command=self.val0).grid()
self.index = IntVar()
self.index.set(0)
self.index.trace("w",self.nextTurn)
def val0(self):
print("User action")
self.index.set(self.index.get() +1)
def val1(self):
print("Automatic action")
self.index.set(self.index.get() +1)
def nextTurn(self, *args):
i = self.index.get()
if i >= len(row):
self.master.destroy()
return
if row[i] == 1:
self.val1()
if __name__ == "__main__":
row = [0,1,0,0,1,1,0,0,0,1,1,1]
root = Tk()
win = Application(root)
root.mainloop()
You can easily solve your problem by calling the nextTurn directly in the automatic action function:
def val1(self):
print("Automatic action")
self.index.set(self.index.get() +1)
self.nextTurn() # call nextTurn after this action
So if it was an automatic action, you step into the next row position, and call nextTurn again.
However this may become a problem if your row becomes too large because it uses recursion. In that case you will want another approach with the while you mentioned. For this second option you would only need to change nextTurn:
def nextTurn(self, *args):
i = self.index.get()
# while it is an automatic action and it has row values, keep calling val1
while i < len(row) and row[i] == 1:
self.val1() # call automatic action
i = self.index.get() #update the row position
else:
if i >= len(row):
self.master.destroy()
return
On a tkinter text widget, the default behavior of double click will be to select the text under the mouse.
The event will select all characters between " " (space) char.
So - assume the text widget has:
1111111 222222
double click on over the first word (all 1) will select only it (and double clicking on 2 word will select it)
I would like to have a similar behavior, but add additional char as work seperators (e.g., ., (, ))
currently, if the text has 111111.222222 - double click anywhere over the text will highlight all characters (won't separate the words by .)
Is there a way to do it?
Changing what is a 'word'
The double click is defined to select the 'word' under the cursor. If you are wanting to change the default behavior for all text widgets, tkinter has a way to tell it what is a "word" character. If you change what tkinter thinks is a "word", you change what gets selected with a double-click. This requires that we directly call the built-in tcl interpreter upon which tkinter is based.
Note: this will affect other aspects of the widget as well, such as key bindings for moving the cursor to the beginning or ending of a word.
Here's an example:
import tkinter as tk
def set_word_boundaries(root):
# this first statement triggers tcl to autoload the library
# that defines the variables we want to override.
root.tk.call('tcl_wordBreakAfter', '', 0)
# this defines what tcl considers to be a "word". For more
# information see http://www.tcl.tk/man/tcl8.5/TclCmd/library.htm#M19
root.tk.call('set', 'tcl_wordchars', '[a-zA-Z0-9_.,]')
root.tk.call('set', 'tcl_nonwordchars', '[^a-zA-Z0-9_.,]')
root = tk.Tk()
set_word_boundaries(root)
text = tk.Text(root)
text.pack(fill="both", expand=True)
text.insert("end", "foo 123.45,678 bar")
root.mainloop()
Custom key binding
If you do not want to affect any widget except one, or do not want to affect other aspects of tkinter that depend on the definition of a 'word', you can create your own binding to select whatever you want.
The important thing to remember is that your binding should return the string "break" in order prevent the default behavior for double-click:
def handle_double_click(event):
<your code for selecting whatever you want>
return "break"
...
text.bind("<Double-1>", handle_double_click)
To facilitate this, the text widget has a search method that makes it possible to search backwards and forwards through the text for a given string or regular expression.
Is there a way to do it?
Of course, and not even one way. But anyway - we need a custom class for our Text widget, so let's start:
class CustomText(tk.Text):
def __init__(self, parent, delimiters=[]):
tk.Text.__init__(self, parent)
# test text
self.insert('1.0', '1111111 222222'
'\n'
'1111111.222222'
'\n'
'1111111.222222,333333'
'\n'
'444444444444444444')
# binds
self.bind('<Double-1>', self.on_dbl_click)
self.bind('<<Selection>>', self.handle_selection)
# our delimiters
self.delimiters = ''.join(delimiters)
# stat dictionary for double-click event
self.dbl_click_stat = {'clicked': False,
'current': '',
'start': '',
'end': ''
}
The optional delimiters leads to two options:
If delimiters are presented we can rely on RegEx search for delimiters.
If delimiters are ommited we can rely on build-in expressions, especially those two (RegEx-like word boundaries): wordstart and wordend. According to docs:
wordstart and wordend moves the index to the beginning (end) of the current word. Words are sequences of letters, digits, and underline, or single non-space characters.
The logic is simple - when double click occures - we trace this event and store indexes in a dictionary. After that we handle change of the selection, and act accordingly the choosen option (see above).
Here's a complete snippet:
try:
import tkinter as tk
except ImportError:
import Tkinter as tk
class CustomText(tk.Text):
def __init__(self, parent, delimiters=[]):
tk.Text.__init__(self, parent)
# test text
self.insert('1.0', '1111111 222222'
'\n'
'1111111.222222'
'\n'
'1111111.222222,333333'
'\n'
'444444444444444444')
# binds
self.bind('<Double-1>', self.on_dbl_click)
self.bind('<<Selection>>', self.handle_selection)
# our delimiters
self.delimiters = ''.join(delimiters)
# stat dictionary for double-click event
self.dbl_click_stat = {'clicked': False,
'current': '',
'start': '',
'end': ''
}
def on_dbl_click(self, event):
# store stats on dbl-click
self.dbl_click_stat['clicked'] = True
# clicked position
self.dbl_click_stat['current'] = self.index('#%s,%s' % (event.x, event.y))
# start boundary
self.dbl_click_stat['start'] = self.index('#%s,%s wordstart' % (event.x, event.y))
# end boundary
self.dbl_click_stat['end'] = self.index('#%s,%s wordend' % (event.x, event.y))
def handle_selection(self, event):
if self.dbl_click_stat['clicked']:
# False to prevent a loop
self.dbl_click_stat['clicked'] = False
if self.delimiters:
# Preserve "default" selection
start = self.index('sel.first')
end = self.index('sel.last')
# Remove "default" selection
self.tag_remove('sel', '1.0', 'end')
# search for occurrences
occurrence_forward = self.search(r'[%s]' % self.delimiters, index=self.dbl_click_stat['current'],
stopindex=end, regexp=True)
occurrence_backward = self.search(r'[%s]' % self.delimiters, index=self.dbl_click_stat['current'],
stopindex=start, backwards=True, regexp=True)
boundary_one = occurrence_backward + '+1c' if occurrence_backward else start
boundary_two = occurrence_forward if occurrence_forward else end
# Add selection by boundaries
self.tag_add('sel', boundary_one, boundary_two)
else:
# Remove "default" selection
self.tag_remove('sel', '1.0', 'end')
# Add selection by boundaries
self.tag_add('sel', self.dbl_click_stat['start'], self.dbl_click_stat['end'])
root = tk.Tk()
text = CustomText(root)
text.pack()
root.mainloop()
In conclusion, if you doesn't really care about delimiters, but about words - the second option is OK, otherwise - the first one.
Update:
Many thanks to #Bryan Oakley for pointing that 'break'-string prevents the default behaviour, so code can be shortened to just one callback, there's no need in <<Selection>> anymore:
...
def on_dbl_click(self, event):
if self.delimiters:
# click position
current_idx = self.index('#%s,%s' % (event.x, event.y))
# start boundary
start_idx = self.search(r'[%s\s]' % self.delimiters, index=current_idx,
stopindex='1.0', backwards=True, regexp=True)
# quick fix for first word
start_idx = start_idx + '+1c' if start_idx else '1.0'
# end boundary
end_idx = self.search(r'[%s\s]' % self.delimiters, index=current_idx,
stopindex='end', regexp=True)
else:
# start boundary
start_idx = self.index('#%s,%s wordstart' % (event.x, event.y))
# end boundary
end_idx = self.index('#%s,%s wordend' % (event.x, event.y))
self.tag_add('sel', start_idx, end_idx)
return 'break'
...
I am writing a typing program that includes many more characters than are available on a standard keyboard. In order to achieve this I need to transform some of the alphabet keys into modifier keys CTRL+A. For example f+j would output a. Typing f then j is slow for the user, I need them to be able to press f and j at the same time and receive one output. It's fine (preferable even) if some of the keyboard's normal functionality is stopped while the program is running.
I have looked into pygame Keydown, but it only seems to have functions for increasing key repeat and not stopping key output. Pyglet is also a possibility, but it doesn't have exact documentation on how I could make additional modifier keys. The only way I can figure out is to be constantly scanning the whole keyboard to see if any keys are pressed, but that won't determine the order the keys are pressed in and will create errors for the user, as the user pressing f then j would be read the same as the user pressing j then f and I need only the f then j combo to be understood as a keystroke by the system.
Here's a Pyglet version of how you could do it.
I based it on common GUI class that I use often here on SO because it's modular and easier to build on without the code getting messy after 40 lines.
import pyglet
from pyglet.gl import *
key = pyglet.window.key
class main(pyglet.window.Window):
def __init__ (self):
super(main, self).__init__(800, 800, fullscreen = False)
self.x, self.y = 0, 0
#self.bg = Spr('background.jpg')
self.output = pyglet.text.Label('',
font_size=14,
x=self.width//2, y=self.height//2,
anchor_x='center', anchor_y='center')
self.alive = 1
self.pressed = []
self.key_table = {213 : 'a'}
def on_draw(self):
self.render()
def on_close(self):
self.alive = 0
def on_key_release(self, symbol, modifiers):
if symbol == key.LCTRL:
pass # Again, here's how you modify based on Left CTRL for instance
## All key presses represents a integer, a=97, b=98 etc.
## What we do here is have a custom key_table, representing combinations.
## If the sum of two pressed matches to our table, we add that to our label.
## - If no match was found, we add the character representing each press instead.
## This way we support multiple presses but joined ones still takes priority.
key_values = sum(self.pressed)
if key_values in self.key_table:
self.output.text += self.key_table[key_values]
else:
for i in self.pressed:
self.output.text += chr(i)
self.pressed = []
def on_key_press(self, symbol, modifiers):
if symbol == key.ESCAPE: # [ESC]
self.alive = 0
elif symbol == key.LCTRL:
pass # Modify based on left control for instance
else:
self.pressed.append(symbol)
def render(self):
self.clear()
#self.bg.draw()
self.output.draw()
self.flip()
def run(self):
while self.alive == 1:
self.render()
# -----------> This is key <----------
# This is what replaces pyglet.app.run()
# but is required for the GUI to not freeze
#
event = self.dispatch_events()
x = main()
x.run()
It might look like a lot of code, especially to the Pygame answer. But you could condense this down to ~15 lines as well, but again, the code would get messy if you tried to build on it any further.
Hope this works. Now I haven't thought the math through on this one.. It might be possible that two duplicate key combinations will produce the same value as another key representation, simply replace the dictionary keys 213 for instance with a tuple key such as self.key_table = {(107, 106) : 'a'} which would represent k+j
Few benefits:
No need to keep track of delay's
Fast and responsive
Any key could be turned into a modifier or map against custom keyboard layouts, meaning you could turn QWERTY into DWORAK for this application alone.. Not sure why you would want that, but hey.. None of my business :D
Overrides default keyboard inputs, so you can intercept them and do whatever you want with them.
Edit: One cool feature would be to register each key down but replace the last character with the joined combination.. Again this is all manual works since a keyboard isn't meant to do double-key-representations, and it's more of a graphical idea.. But would be cool :)
Here is some simple code to print keys pressed in quick succession, written in Python 2. It should be able to easily be modified to suit your needs:
import pygame, sys
pygame.init()
screen = pygame.display.set_mode([500,500])
clock = pygame.time.Clock()
combokeys = []
timer = 0
ACCEPTABLE_DELAY = 30 #0.5 seconds
while 1:
clock.tick(60)
timer += 1
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
if event.type == pygame.KEYDOWN:
if timer <= ACCEPTABLE_DELAY:
combokeys.append(event.unicode)
else:
combokeys = [event.unicode]
timer = 0
print combokeys
I have not been able to test this code (working at school computer), so please notify me in the comments if something did not work so I can fix it.
You can change the value given for ACCEPTABLE_DELAY to change the delay before something is considered a different key combination. The delay should be (ACCEPTABLE_DELAY/60) seconds.
Is there a way to add undo and redo capabilities in Tkinter Entry widgets or must I use single line Text widgets for this type of functionality?
If the latter, are there any tips I should follow when configuring a Text widget to act as an Entry widget?
Some features that might need tweaking include trapping the Return KeyPress, converting tab keypresses into a request to change focus, and removing newlines from text being pasted from the clipboard.
Check the Tkinter Custom Entry. I have added Cut, Copy, Paste context menu, and undo redo functions.
# -*- coding: utf-8 -*-
from tkinter import *
class CEntry(Entry):
def __init__(self, parent, *args, **kwargs):
Entry.__init__(self, parent, *args, **kwargs)
self.changes = [""]
self.steps = int()
self.context_menu = Menu(self, tearoff=0)
self.context_menu.add_command(label="Cut")
self.context_menu.add_command(label="Copy")
self.context_menu.add_command(label="Paste")
self.bind("<Button-3>", self.popup)
self.bind("<Control-z>", self.undo)
self.bind("<Control-y>", self.redo)
self.bind("<Key>", self.add_changes)
def popup(self, event):
self.context_menu.post(event.x_root, event.y_root)
self.context_menu.entryconfigure("Cut", command=lambda: self.event_generate("<<Cut>>"))
self.context_menu.entryconfigure("Copy", command=lambda: self.event_generate("<<Copy>>"))
self.context_menu.entryconfigure("Paste", command=lambda: self.event_generate("<<Paste>>"))
def undo(self, event=None):
if self.steps != 0:
self.steps -= 1
self.delete(0, END)
self.insert(END, self.changes[self.steps])
def redo(self, event=None):
if self.steps < len(self.changes):
self.delete(0, END)
self.insert(END, self.changes[self.steps])
self.steps += 1
def add_changes(self, event=None):
if self.get() != self.changes[-1]:
self.changes.append(self.get())
self.steps += 1
Disclaimer: these are just thoughts that come into my mind on how to implement it.
class History(object):
def __init__(self):
self.l = ['']
self.i = 0
def next(self):
if self.i == len(self.l):
return None
self.i += 1
return self.l[self.i]
def prev(self):
if self.i == 0:
return None
self.i -= 1
return self.l[self.i]
def add(self, s):
del self.l[self.i+1:]
self.l.append(s)
self.i += 1
def current(self):
return self.l[self.i]
Run a thread that every X seconds (0.5?) save the state of the entry:
history = History()
...
history.add(stringval.get())
You can also set up events that save the Entry's status too, such as the pressure of Return.
prev = history.prev()
if prev is not None:
stringvar.set(prev)
or
next = history.next()
if next is not None:
stringvar.set(next)
Beware to set locks as needed.
Update on using this method for Undo/Redo:
I am creating a GUI with lot of frames and each contains at least ten or more 'entry' widgets.
I used the History class and created one history object for each entry field that I had. I was able to store all entry widgets values in a list as done here.
I am using 'trace' method attached to each entry widget which will call 'add' function of History class and store each changes. In this way, I was able to do it without running any thread separately.
But the biggest drawback of doing this is, we cannot do multiple undos/redos with this method.
Issue:
When I trace each and every change of the entry widget and add that to the list, it also 'traces' the change that happens when we 'undo/redo' which means we cannot go more one step back. once u do a undo, it is a change that will be traced and hence the 'undo' value will be added to the list at the end. Hence this is not the right method.
Solution:
Perfect way to do this is by creating two stacks for each entry widget. One for 'Undo' and one for 'redo'. When ever there is a change in entry, push that value into the undo stack. When user presses undo, pop the last stored value from the undo stack and importantly push this one to the 'redo stack'. hence, when the user presses redo, pop the last value from redo stack.
Based on Evgeny's answer with a custom Entry, but added a tkinter StringVar with a trace to the widget to more accurately track when changes are made to its contents (not just when any Key is pressed, which seemed to add empty Undo/Redo items to the stack). Also added a max depth using a Python deque.
If we're changing the contents of the Entry via code rather than keyboard input, we can temporarily disable the trace (e.g. see in the undo method below).
Code:
class CEntry(tk.Entry):
def __init__(self, master, **kw):
super().__init__(master=master, **kw)
self._undo_stack = deque(maxlen=100)
self._redo_stack = deque(maxlen=100)
self.bind("<Control-z>", self.undo)
self.bind("<Control-y>", self.redo)
# traces whenever the Entry's contents are changed
self.tkvar = tk.StringVar()
self.config(textvariable=self.tkvar)
self.trace_id = self.tkvar.trace("w", self.on_changes)
self.reset_undo_stacks()
# USE THESE TO TURN TRACE OFF THEN BACK ON AGAIN
# self.tkvar.trace_vdelete("w", self.trace_id)
# self.trace_id = self.tkvar.trace("w", self.on_changes)
def undo(self, event=None): # noqa
if len(self._undo_stack) <= 1:
return
content = self._undo_stack.pop()
self._redo_stack.append(content)
content = self._undo_stack[-1]
self.tkvar.trace_vdelete("w", self.trace_id)
self.delete(0, tk.END)
self.insert(0, content)
self.trace_id = self.tkvar.trace("w", self.on_changes)
def redo(self, event=None): # noqa
if not self._redo_stack:
return
content = self._redo_stack.pop()
self._undo_stack.append(content)
self.tkvar.trace_vdelete("w", self.trace_id)
self.delete(0, tk.END)
self.insert(0, content)
self.trace_id = self.tkvar.trace("w", self.on_changes)
def on_changes(self, a=None, b=None, c=None): # noqa
self._undo_stack.append(self.tkvar.get())
self._redo_stack.clear()
def reset_undo_stacks(self):
self._undo_stack.clear()
self._redo_stack.clear()
self._undo_stack.append(self.tkvar.get())