Events in our text editor

First things first: we should ensure that we have some expected key-bindings happening within our Text widget. We'll collate these in a method called bind_events and call this method from within our __init__:

def __init__(self):
...
self
.bind_events()

def bind_events(self):
self.bind('<Control-a>', self.select_all)
self.bind('<Control-c>', self.copy)
self.bind('<Control-v>', self.paste)
self.bind('<Control-x>', self.cut)
self.bind('<Control-y>', self.redo)
self.bind('<Control-z>', self.undo)

This function now ensures that six commonly used keyboard shortcuts will perform their expected behaviors.

Since these behaviors are already handled by the Text widget (except for select_all), we only need to emit the relevant events in order to get them to function. The select_all  method is the only one we need to perform the logic for:

def cut(self, event=None):
self.event_generate("<<Cut>>")

def copy(self, event=None):
self.event_generate("<<Copy>>")

def paste(self, event=None):
self.event_generate("<<Paste>>")

def undo(self, event=None):
self.event_generate("<<Undo>>")

return "break"

def redo(self, event=None):
self.event_generate("<<Redo>>")

return "break"

Our five built-in behaviors simply emit an event of the same name which will be handled by the Text widget at a class level. We return the break string in two of them in order to prevent the default behavior of their key combinations.

Now that we have defined these methods, we can be certain that key-bindings such as Ctrl + c will definitely copy text, and Ctrl + v will paste text. We do not need to return break for three of the events as there is no further default behavior, but if you wanted to do so for extra certainty, then it would not hurt:

def select_all(self, event=None):
self.tag_add("sel", 1.0, tk.END)

return "break"

In order to implement the select_all method, we add a tag of sel to the entire document. Don't worry about understanding how this works yet; we will go over tags and indexing in great detail in the next chapter.

That's all of the events for the TextArea widget. We can now make some more improvements to the MainWindow class.

A common feature of text editors is the ability to display line numbers. With the first iteration of our editor, we can implement a rough version of this feature (to be improved in Chapter 6, Color Me Impressed! – Adding Syntax Highlighting).

Go ahead and add the following to the __init__ method of the MainWindow class:

self.line_numbers = tk.Text(self, bg="grey", fg="white")
first_100_numbers = [str(n+1) for n in range(100)]

self.line_numbers.insert(1.0, " ".join(first_100_numbers))
self.line_numbers.configure(state="disabled", width=3)

In order to represent the line numbers, we will be using another Text widget. The colors will be different from the TextArea widget to help differentiate between the two.

We use the range function to generate the first 100 numbers and insert them into our Text widget, separated by new line characters. 

The configure method is then used to disable the widget, preventing the user from entering different text inside it, and also to set the width to three characters, which is as wide as it needs to be at the moment.

The line numbers will live on the left-hand side of the application now, so we will move the Scrollbar over to the right to make the interface a bit nicer:

self.scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.line_numbers.pack(side=tk.LEFT, fill=tk.Y)
self.text_area.pack(side=tk.LEFT, fill=tk.BOTH, expand=1)

Give this code a whirl and check out the new layout.

If you enter many lines of text, you will still use the mouse and Scrollbar to scroll the TextArea widget, but you will notice that our line numbers don't move along with it. To fix this, we will revisit our scroll_text method:

def scroll_text(self, *args):
self.line_numbers.yview_moveto(args[1])
self.text_area.yview_moveto(args[1])

Running the code now, you will see that the Scrollbar will move the line numbers and TextArea together, but the mouse wheel still operates on each independently. To alter this behavior, we will need to use some event bindings.

We'll go ahead and use another bind_events method to collate all of these:

def bind_events(self):
self.text_area.bind("<MouseWheel>", self.scroll_text)
self.text_area.bind("<Button-4>", self.scroll_text)
self.text_area.bind("<Button-5>", self.scroll_text)

Different operating systems will report different events, so we bind each possible one to our scroll_text method.  This ensures all platforms will behave correctly.

We will now need to update the scroll_text method to handle the event argument which is passed by Tkinter's event system:

def scroll_text(self, *args):
if len(args) > 1:
self.text_area.yview_moveto(args[1])
self.line_numbers.yview_moveto(args[1])
else:
event = args[0]
if event.delta:
move = -1 * (event.delta / 120)
else:
if event.num == 5:
move = 1
else:
move = -1

self.text_area.yview_scroll(int(move), "units")
self.line_numbers.yview_scroll(int(move), "units")

Since the event system will pass a single event object as the only argument, we will only have one argument caught by our args* parameter. We can use this to detect whether or not we have entered this method by dragging the Scrollbar or moving the mouse wheel.

Our previous code is now in an if else block, with all new logic inside the else.

We grab the event object from the args list and check its properties. Again, different operating systems will populate different attributes in our event object.

If the delta attribute has been populated, we will need to scale it down to fit with Tkinter's scale. We do this by dividing it by 120. We also need to reverse the direction, so we multiply it by -1. The result is stored in a variable named move which will be used to adjust how far, and in which direction, the widgets are scrolled.

If we do not have a delta attribute, then we should have a num attribute instead. Much like the mouse button codes, a num of 4 is a scroll up and a num of 5 is a scroll down. We decide the appropriate unit of movement with an if statement and again store it in our move variable.

Now that we have the unit of movement, we apply it to the widgets using the yview_scroll method. We cast this to an integer to ensure that the division from the delta attribute has not resulted in a float, and use the special units string to tell Tkinter that we are scrolling by one unit each time. The other option is pages, which would result in a much larger scroll.

With this method finished, our line numbers and TextArea will scroll together via both means. Despite this, the line numbers can still be scrolled by themselves, which we do not want to happen. We need to remove the default binding from the mouse wheel on this widget. We can use the break string to achieve this.

Add these three lines into the bind_events function:

self.line_numbers.bind("<MouseWheel>", lambda e: "break")
self.line_numbers.bind("<Button-4>", lambda e: "break")
self.line_numbers.bind("<Button-5>", lambda e: "break")

Using a lambda function to return the string break will prevent the class-level scrolling bindings from occurring on the Text widget we are using as line numbers.

Run this version of the text editor and try playing around with the scrolling properties. You should see that the line numbers and TextArea now scroll together, and you cannot scroll the line numbers independently by scrolling within their widget.

Due to the hardcoded amount of line numbers at the side, you will be able to scroll them down further than the TextArea widget, but this last small detail will be fixed in the next chapter when we begin dynamically populating the line numbers, so there is no need to worry about that yet.

The last thing to cover in this chapter will be creating a second top-level window. Luckily for us, Tkinter has a widget which will allow us to do just that. This widget will be great for our find/replace box.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset