5. Material: Windowed Interfaces¶
This is an optional extra material that is necessary for the final project topics. The contents of this material are not included in the exam. It's recommended to complete the exam before diving into this material.
Let's be real here: text-based terminal programs are a bit too 80s. In these modern times we should at least try to do something that opens in its own window, and can be poked at with a mouse or touchscreen. As our finishing touch we'll move the collection manager from the terminal to a windowed interface. The things in this section aren't really elementary in any way or shape, but a lot of modern programming is based on some of the concepts we're about to learn. Furthermore, modern tools for making graphics are so cool that even with just a scratch to the surface one can do pretty impressive stuff.
As usual, we're not diving in head first without a plan. Reaching for the moon from the sky might be a bit beyond us so we'll rather be content with the most straightforward way to transform the current text-based interface to a windowed one. All main features should be accessible from buttons in the main window, and submenus should open in separate windows as needed. The collection can be seen in a table or text box in the main window. We need a
library
that can offer these basic UI features.One thing to keep in mind when reading through this example is that it's more complex than the minimum requirements for the course project - you'll get away with less.
Learning goals: In this section you'll learn the very bare minimum about how modern(-ish) user interface libraries work. In particular this includes the inner workings of
handler functions
and how to share information between them. In addition you'll learn how to use one of the graphical user interface libraries that we made specifically for this course. The other is introduced in the last exercise example.Library Tour¶
At this stage we'd normally do some research about which library is the best for what we planned. However, we don't really have enough programming experience yet to make a reasonable evaluation, so we'll skip that. Python comes with the TKinter library that's older than stone weapons and produces interfaces that are uglier than a salty Dota player's behavior at 3 am, but it does offer all the basics for graphical user interfaces in a relatively simple manner. Not simple enough that we'd go through it in this context though. Instead, we've made a module that simplifies some of TKinter's features into several
functions
that are a bit easier to comprehend. The code has also been documented rather extensively with docstrings
This is the same library as the one used in Spectral Matters and Run, Circuit! Run! course projects, but we removed the matplotlib connections so that minestompers don't need to install it just to test these examples. We'll only cover the parts of the library that are actually needed here, figuring out the rest is left as homework while doing the course project.
Callback to Wonderland¶
We used
callbacks
with minimal explanation when working on sorting lists
. To recap, we were able to give the sort method
a function
as an argument, and that function was used during sorting to derive comparison values
from the lists's items
. We used this power to choose what property of the list's items was used for sorting, like this:collection.sort(key=choose_length, reverse=reverse)
The special thing here is that we're not calling the choose_length function at any point in our own code - it is called when the program's control has been temporarily handed over to the sort method instead. Giving the function - not its return value - as an argument here instructs the sort method that this is the function it should call when it needs to obtain comparison values. Because the function is called from the sort method, the arguments given to it are also determined there. This means they're not in our control, and we also cannot control what is done with the returned value. When implementing a callback function it's very important to research what arguments are given to the function and what is done with its return value. This information tells us the number of
parameters
and their null-missing-termtypes[!term!], as well as what the function should return
. The function in our example had exactly one parameter (one item from a list) and it returned exactly one value:def choose_length(album):
return album["length"]
Reviewing this is important because the same mechanism is found in user interface and game libraries, but the scale is larger. Typically the main loop that runs the entire program is somewhere deep inside the library. In our program the main loop is currently the
while True: loop in the menu function, and something like this will not be seen in our code once it's changed to work with an inteface library. In a way the program's flow is outside our control. The reason is quite plain: the main loop needs to react to every interaction the user has with the interface, and that results in quite a lot of code. Writing all this code by ourselves isn't particularly pleasant, and probably the library already does it better.Of course this leaves us wondering about how to implement anything at all if the program's control has been removed from our hands. This is where callbacks come in, and in this context they're also called
handler functions
. Before starting the main loop, we can tell the library what kinds of events
are interesting to us. Event means something happening, like the user interacting with the interface. We can attach handler functions to these events. Whenever the designated event occurs, the attached handler is called.With user interface libraries it's common that each active user interface component has its own handler. This is set up when the components are defined. For instance, if we want a button, we can attach a handler to it when it is created, and this handler will be called by the library when the user clicks the button. Our simple library has this set up so that there's only one function for creating a button, and it takes three arguments:
- frame component where the button will be placed (see next section)
- the text on the button (string)
- function that will be the handler
Below is a high level description of what happens when a program using an interface library is started up.
- our program defines its user interface components along with their handlers
- our program calls the function that starts the library's main loop
- the library follows the user's actions
- the user does something that is interesting to our program (i.e. there's an eventthat we attached ahandlerto)
- the library calls the handler function, effectively returning control to our program
- after the handler function returns, control moves back to the library
- a handler function in our program calls a function that exits the library's main loop, and control is returned to our program
- our program can perform cleanup before exiting (e.g. saving data)
- our program exits
Interface Simulator¶
Before moving on, it's best to look at the following approximation of a user interface library in order to get a better understanding of how they work in general. The code presented here several multitudes less complex than a real user interface library but works with a similar logic. The functions of the library can be used to define buttons, and it can be started. Once running, it will detect "clicks" (that are produced as random integer pairs instead of actually reading the mouse), and performs actions using the buttons' handler functions if a click hits one of them.
The single most important thing about this approximation is that you could replace it with the guilib library at any time - the function interfaces are exactly the same. In order to understand how user interface libraries work in general, we need to look at two specific functions in this approximation, and how those functions would be used in a program. In order to do that, we've also created the following small program that creates an interface with a few buttons.
Executing the code does not create a real interactive window because it only simulates what an actual library would do. Instead, you will see dots printed into the
terminal
, and occasionally either "donkey" or "hemulen". Each dot is a single mouse click, and the appearance of a word means a button was pressed by a click. The program will also end when the quit button is hit. A run of the program could look like this:..................................donkey ............hemulen ..donkey ......................................................hemulen ..................donkey ..so long, and thanks for all the fish
Because the simulator's code is much more simple, causal relations are easier to follow. Let's look at two functions in particular. Our goal is to understand why the program itself (i.e. librarytest.py) works like it does. The first half of the puzzle is the interface layout. From the program's viewpoint, an interface consists of frames (columns) and buttons (rows inside columns). The program can create frames, and push buttons into them. On the library side this is handled by the
create_button function.def create_button(frame, label, action):
left = window.index(frame) * BUTTON_WIDTH
right = left + BUTTON_WIDTH
top = len(frame) * BUTTON_HEIGHT
bottom = top + BUTTON_HEIGHT
frame.append({
"left": left,
"right": right,
"top": top,
"bottom": bottom,
"label": label,
"action": action
})
This function calculates the position of each button inside the window, and saves the x and y values of its edges to a
dictionary
. This marks the region of the window that belongs to the button. The width of each button is 200 units and height is 60 units. The placement is based on the frame's index
in the window list, and the amount of buttons already inside the frame. The other very important thing that's saved into the dictionary is the value of the action parameter
. This value is a function
that performs the action designated for he button. It's very important to note that the function is not called yet!On the side of the program itself, a button is created by calling the
create_button function, after we've defined the function that will be the button's handler.def print_donkey():
print("donkey")
window = library.create_window("test")
frame = library.create_frame(window)
library.create_nappi(frame, "nappi 1", print_donkey)
Please pay attention to how the function is handled like any old
variable
: it is not called here, only handed as an argument
. The full example creates three buttons, which results in the following "interface":
The buttons are regions inside the window, and clicking the mouse within that area causes the button to be pressed. This is the point where control is given to the library - to its
start function to be precise. Here, we've removed some stuff from the full function to make it easier to see its logic:def detect_button(x, y, window):
for frame in window:
for button in frame:
if button["left"] <= x <= button["right"]:
if button["top"] <= y <= button["bottom"]:
function = button["action"]
function()
return
def start():
state["running"] = True
while state["running"]:
print(".", end="", flush=True)
mouse_x, mouse_y = read_click()
detect_button(mouse_x, mouse_y, window)
# added to prevent the program from running too fast
time.sleep(0.1)
if state["draw"]:
t.done()
The corresponding function in a real user interface library would obviously be much more complex, but ultimately it does the same things:
- read the position of a mouse click
- find if the click was inside a user interface element
- if an element is hit, its action is executed and
- search is ended
This gets repeated in a loop until the program's execution ends. In our simulator, phase 1 is handled by calling the
read_click function that provides x, y coordinates of (imaginary) mouse clicks. Phase 2 is implemented by going through all frames and their buttons in loops, and comparing button boundaries to the click coordinates. If the point is inside a button's boundaries, the button dictionary's "action" key
is used to retrieve a reference
to a function, and then the function is called (without an argument).The key point here is to concretely show the context where handler functions are eventually called. As seen here, the function call's arguments are determined at call time (and this time there aren't any). The syntax also shows that if a variable contains a function, and parentheses are placed at the end of the variable, this results in making a
call
to the referenced function. When this happens, control temporarily returns to the actual program, inside the handler
function. In the case of the first button, this function would be:def print_donkey():
print("donkey")
Therefore donkey is printed to the terminal. The dots printed into the terminal while running the program indicate mouse clicks regardless of whether a button was hit or not. When the library is "closed" with the
quit function, control returns to the actual program ja resumes from the line following the call to the start function call. This is why "so long, and thanks for all the fish" is printed when the program ends.print("so long, and thanks for all the fish")
The library file earlier which contains the full code also has an option to show a visualization of what happens in the window using turtle. You can activate this visualization by adding the
-d or --draw command line argument
when starting the program:python librarytest.py --draw
Note that button labels will not show. The topmost button prints donkey, middle prints hemulen, and the last one quits the program. You can keep the terminal visible alongside the turtle window to see what's printed with each click.
Another cool detail: if you change the import on the first line of code to import guilib instead, you can run the test code, and it will create a real interface instead. So the first line would be:
import guilib as library
Now runnin the program creates an actual window. The window geometry will be different because layouting of elements is done by the library, not the program that uses it. This is described in more detail under the next heading.
Boxes and Packing¶
Before moving on to implementing features with handler functions, we should look into how interface components are defined with code. TKinter uses a method where the interface can be divided into frames and components. A frame is sort of like a
list
in Python in that it can contain other components - including frames. Placement is based on packing against a border (although this is not the only option). When packed, a target direction is determined for a component. For instance, if the direction is up, the component tries to get as far up as possible inside the frame. Components are packed in the order they are added, which means the first added component will be closest to the border it was packed against.
In general all components inside a frame should be packed to the same direction to avoid silly holes in the interface. In terms of simplicity this is exactly what our custom library does: all components inside each frame are packed against the top border. Only the packing direction of frames themselves can be changed when using our custom library. The library also hides a bunch of other placement related settings that TKinter offers, which means it limits options quite a bit. However it's not much of a loss. If you really want interfaces that look good, you should look further than TKinter. One example is PySide 2 that translates Qt, a way more powerful (but also way more complex) interface library to Python.
Shown below is a function that creates the shiny new graphical interface of our collection manager program, followed by a sceenshot what it looks like (on Linux). We've also added a quit function that serves as the
handler
for the quit button.import guilib as ui
def quit():
ui.quit()
def create_window():
window = ui.create_window("Collection Manager 0.1 alpha")
button_frame = ui.create_frame(window, ui.LEFT)
collection_frame = ui.create_frame(window, ui.LEFT)
load_button = ui.create_button(button_frame, "Load", load_collection)
construct_button = ui.create_button(button_frame, "Construct", construct_collection)
save_button = ui.create_button(button_frame, "Save", save_collection)
ui.create_horiz_separator(button_frame, 5)
add_button = ui.create_button(button_frame, "Add", add)
remove_button = ui.create_button(button_frame, "Remove", remove)
edit_button = ui.create_button(button_frame, "Edit", edit)
ui.create_horiz_separator(button_frame, 5)
quit_button = ui.create_button(button_frame, "Quit", quit)
listbox = ui.create_listbox(collection_frame)
ui.start()
if __name__ == "__main__":
#source, target = read_arguments(sys.argv)
try:
create_window()
except KeyboardInterrupt:
print("Program was interrupted, collection was not saved.")
The functions for creating buttons and other components generally say they return an
object
. As of now we save of all them to variables
in order to refer to them later. We don't actually know if we need to refer to them later though. Frames are clearly referred to inside this same function but buttons aren't. Separators aren't active components in the interface so the library doesn't even bother with returning them. Another thing worth of note is that while we can run the code at the moment, most buttons do not work (except quit). The main program's been changed to call the create_window function instead of the menu function, and we've commented out the part about reading command line arguments.Information Smuggling¶
We don't need to look far to find out why the buttons are not working. The create_button function in the library has the following to say in its
docstring
Creates a button that the user can click. Buttons work through handler
functions. There must be a function in your code that is called whenever
the user presses the button. This function doesn't receive any arguments.
The function needs to be given to this function as its handler argument.
E.g.:
def donkey_button_handler():
# something happens
create_button(frame, "donkey", donkey_button_handler)
Buttons are always packed against the top border of their frame which means
they will be stacked on top of each other. If you want to pack them in a
different way, you can always use this function as an example and write
your own.
:param widget frame: frame that will host the buttons
:param str label: text on the button
:param function handler: function that is called when the button is pressed
:return: returns the created button object
The
handler
doesn't receive any arguments
whereas our existing functions do expect to get some. In other words they are not fit to be used as handlers as they are. There's no reason to throw them away entirely though. For instance, load_collection still does its job perfectly well. We just need to give it the path
to the collection file in some other way. With a little bit of further investigation we can discover a promising function from the library: open_file_dialog. Let's create a new function that calls the existing load_collection function once it's received a path from the open_file_dialog function. The same can be done for the construction feature (they both beed a different selection dialog). We'll also remove the input from construct_collection and change the folder to a parameter
.def construct_collection(folder):
try:
collection = sniffer.read_collection(folder)
except FileNotFoundError:
print("Folder not found")
return collection
def open_load_window():
path = ui.open_file_dialog("Select collection file (JSON)")
collection = load_collection(path)
def open_construct_window():
path = ui.open_folder_dialog("Select music collection root folder")
collection = construct_collection(path)
This introduces another problem: the handler also cannot return anything, so how do we get the loaded/constructed collection to show up in other parts of the program? This is where the fact that
lists
and dictionaries
are mutable
becomes handy. If a mutable object
is defined in the global scope
it can be accessed in all functions. This time we use some foresight and create a dictionary. This will allow us to assign other objects that we might want to share to its keys
.components = {
"collection": []
}
As a side note, Pylint will complain about this (although we've disabled that particular warning in the checkers) because it thinks this dictionary is a
constant
since it's in the global scope. However the data contained within this object will most definitely change during program execution, so giving it an uppercase name would by misleading. We can now change the load and construct functions to assign the collection into this dictionary:def load_collection(filename):
try:
with open(filename, encoding="UTF-8") as source:
components["collection"] = json.load(source)
except (IOError, json.JSONDecodeError):
print("Unable to open the target file. Starting with an empty collection.")
components["collection"] = []
def construct_collection(folder):
try:
components["collection"] = sniffer.read_collection(folder)
except FileNotFoundError:
print("Folder not found")
def open_load_window():
path = ui.open_file_dialog("Select collection file (JSON)")
load_collection(path)
show(components["collection"])
def open_construct_window():
path = ui.open_folder_dialog("Select music collection root folder")
construct_collection(path)
show(components["collection"])
Since the returns were removed, the corresponding assignment of return values also had to go. We can now load or construct the collection. Now we need to make it visible in the interface. We have a function for this called add_list_row in the library, but we need to give it a listbox as an argument. Currently our listbox only exists inside the create_window function. The best way to make it available elsewhere is to put into this new dictionary we cooked up. Let's rewrite the printing functions to write into the listbox instead of the terminal.
def format_row(album, i):
return (
f"{i:2}. "
f"{album['artist']} - {album['album']} ({album['year']}) "
f"[{album['no_tracks']}] [{album['length'].lstrip('0:')}]"
)
def show(collection):
for i, album in enumerate(collection):
ui.add_list_row(components["listbox"], format_row(album, i + 1))
In order for the listbox to be available like this, it needs to be saved into the dictionary when it's created, and we can do it like this:
components["listbox"] = ui.create_listbox(collection_frame). A single row is formatted in its own function because we predict it might be needed for updating a row after an album has been edited. Now we can achieve a nicely printed collection inside the window.Popping Windows¶
This section contains a lot of code but not that many new concepts. The goal is to make it possible to add albums again. Since this was previously done with
text inputs
, a small legion of changes is needed. The basic concept is that pressing the Add button in the interface opens a new subwindow containing fields for album information. The album is added to the collection when this window is closed - if the fields have valid values. Otherwise we inform the user about their mistake with an error message and let them fix it.The library contains a few
functions
related to subwindows. A subwindow is a way to open another window on top of an existing window. We can place frames and components into them just like the main window. A subwindow can be hidden and showed again with functions. A good way to go about is to create the window at the beginning of the program and then hide it whenever it's not needed. We prefer this over creating the window anew every time. The window will contain text field inputs and labels related to them. The whole thing is created in the original create_window function.def create_window():
# Main window creation
window = ui.create_window("Collection Manager 0.1 alpha")
button_frame = ui.create_frame(window, ui.LEFT)
collection_frame = ui.create_frame(window, ui.LEFT)
load_button = ui.create_button(button_frame, "Load", open_load_window)
construct_button = ui.create_button(button_frame, "Construct", open_construct_window)
save_button = ui.create_button(button_frame, "Save", open_save_window)
ui.create_horiz_separator(button_frame, 5)
add_button = ui.create_button(button_frame, "Add", open_add_window)
remove_button = ui.create_button(button_frame, "Remove", remove)
edit_button = ui.create_button(button_frame, "Edit", edit)
ui.create_horiz_separator(button_frame, 5)
quit_button = ui.create_button(button_frame, "Quit", quit)
components["listbox"] = ui.create_listbox(collection_frame)
# Subwindow creation
album_form = ui.create_subwindow("Album information")
field_frame = ui.create_frame(album_form)
button_frame = ui.create_frame(album_form)
label_frame = ui.create_frame(field_frame)
input_frame = ui.create_frame(field_frame)
ui.create_label(label_frame, "Artist")
components["form_artist"] = ui.create_textfield(input_frame)
ui.create_label(label_frame, "Album")
components["form_album"] = ui.create_textfield(input_frame)
ui.create_label(label_frame, "No. tracks")
components["form_no_tracks"] = ui.create_textfield(input_frame)
ui.create_label(label_frame, "Length")
components["form_length"] = ui.create_textfield(input_frame)
ui.create_label(label_frame, "Release year")
components["form_year"] = ui.create_textfield(input_frame)
ui.create_button(button_frame, "Save", save_form)
ui.hide_subwindow(album_form)
components["album_form"] = album_form
ui.start()
References to each field in the form and to the form itself are needed in the components
dictionary
so that the fields can be read in other parts of the program, and so that we can show and hide the window in the future. We also changed the handler
to a new function that opens the add dialog. Likewise a handler is created for the subwindow's Save button.def open_add_window():
ui.show_subwindow(components["album_form"])
def save_form()
ui.hide_subwindow(components["album_form"])
With these we can open and close the form and see what it looks like. The labels don't quite align with the fields, but we're not going to tune them right now.
Next we need this form to actually do something. This calls for some decision-making and planning. We've decided to use the same form for both adding and editing. We've also decided to save the album when the window is closed (when else?) This means we need to know what purpose the form was opened for, and smuggle this information to the save_form function. We can use the same mechanism as we use for accessing the collection list from everywhere in the program: save this information into the global
dictionary
. While at it we're also going to separate this and the collection list into a second dictionary, and leave the components dictionary only for interface component references.NOT_SELECTED = 0
ADD = 1
EDIT = 2
components = {
"listbox:" None,
"album_form": None,
"form_artist": None,
"form_album": None,
"form_no_tracks": None,
"form_length": None,
"form_year": None
}
state = {
"collection": [],
"action": NOT_SELECTED
}
We've implemented the actions with
constants
. The numeral values of these constants don't matter at all but they're just more practical than strings
, let alone plain number. We've also put None as the value for each key
. This is not mandatory but we've done it in order to show at the very beginning of the code what keys will be available in this dictionary. Using the action information in the state dictionary we can now proceed with the album form.def save_form():
if state["action"] == ADD:
success = add(state["collection"])
place = len(state["collection"]) - 1
elif state["action"] == EDIT:
success = edit(state["collection"])
else:
return
if success:
ui.add_list_row(
components["listbox"],
format_row(state["collection"][place], place + 1),
place
)
ui.clear_field(components["form_artist"])
ui.clear_field(components["form_album"])
ui.clear_field(components["form_no_tracks"])
ui.clear_field(components["form_length"])
ui.clear_field(components["form_year"])
ui.hide_subwindow(components["album_form"])
state["action"] = NOT_SELECTED
The action is set when the form is opened, and its value is checked when the form is closed with the Save button. We've also done some additional processing when the form is closed. We only want to close the form when the user has given valid data. We also need to clear all the fields so that their contents aren't haunting the user the next time they open the form. In case of a successful save the album must also be inserted into the listbox view. Another option would be to clear the entire listbox and then just call the show function that would display the entire collection afresh, but that involves a whole lot of wasted clock cycles. The add function itself becomes quite a bit larger:
def add(collection):
artist = ui.read_field(components["form_artist"])
album = ui.read_field(components["form_album"])
try:
no_tracks = int(ui.read_field(components["form_no_tracks"]))
except ValueError:
ui.open_msg_window("Error in data", "Number of tracks must be an integer", error=True)
return False
try:
length = check_length(ui.read_field(components["form_no_tracks"]))
except ValueError:
ui.open_msg_window("Error in data", "Length must be written as HH:MM:SS", error=True)
return False
try:
year = int(ui.read_field(components["form_year"]))
except ValueError:
ui.open_msg_window("Error in data", "Release year must be an integer", error=True)
return False
collection.append({
"artist": artist,
"album": title,
"no_tracks": no_tracks,
"length": length,
"year": year
})
return True
The main culprit to this function becoming so long is user feedback: each error opens a message popup with a different error message, and that makes all of them require their own try-except. We're now using the library's message popup feature which can be used to open notifications in popup windows. The last argument - which we've given here as a
keyword argument
for increased clarity - tells the library to show the error icon in the popup. The form's contents are read with the read_field function, and this is where we need the references to fields from the components dictionary
. This function returns the field's content as a string. Note that check_length still doesn't actually do anything, but at least we're handling it when it ultimately does.Streamlined Renovation¶
Removing albums used to be very clunky in the program: in order to select an album, the user had to type both album title and artist name. In order for our program to get on with the times it should allow choosing an album from the listbox in the interface with a simple mouse click. This is the primary reason we used a listbox instead of a plain textbox, because in a listbox each row is a clickable entity. Our library has a function for handling this: read_selected. This function returns the index and content of the selected row. The library also has a function for removing a row. With these the remove function becomes a whole lot simpler:
def remove():
index, contents = ui.read_selected(components["listbox"])
if index != None:
state["collection"].pop(index)
ui.remove_row(components["listbox"], index)
This is the first instance of using pop
method
instead of remove to remove an item
. We do this because pop removes based on index instead of value. It would also return the item it removed but we're not doing anything with it so it goes to the bin. The last line is needed to remove the album from the listbox. This leaves us with the minor problem of having a hole in the numbering after a removal. We're going to be lazy about this and "fix" the problem by removing the numbering altogether. Otherwise we'd have to reprint all rows starting from the removed index. Since we removed the collection parameter, this function can be used directly as the Remove button's handler
.The same method of album selection can be used editing. This feature will be a combination of the add feature from before, and the remove feature we just did. We borrow the editing form from the former, and the selection code from the latter. We're going to open the same subwindow as we did with add, but this time each field is prefilled with its current value. In addition the album should be shown in its old place in the listbox after editing. So once again we need to make some decisions about what happens where. The easiest place to start is opening the form.
def open_edit_window():
place = prefill_form()
ui.show_subwindow(components["album_form"])
state["action"] = EDIT
state["selected"] = place
The form must be prefilled at this stage before it is shown. This sounds like a job for a separate function. We also let that function take care of reading the selected album's place in the list (i.e. its index in the collection), and return it. Another decision we made here is saving the selected index to the state
dictionary
. This is done as a safeguard to prevent the user from choosing another album while the form window is open which would overwrite the wrong album with the edited information. The new function is:def prefill_form():
index, contents = ui.read_selected(components["listbox"])
album = state["collection"][index]
ui.write_field(components["form_artist"], album["artist"])
ui.write_field(components["form_album"], album["album"])
ui.write_field(components["form_no_tracks"], album["no_tracks"])
ui.write_field(components["form_length"], album["length"])
ui.write_field(components["form_year"], album["year"])
return index
Now we can open the form and see the existing values in all fields.
The save button handler already exists but the guess we made about how to handle saving an edit wasn't entirely accurate. Let's add some things to it.
def save_form():
if state["action"] == ADD:
success = add(state["collection"])
place = len(state["collection"]) - 1
elif state["action"] == EDIT:
place = state["selected"]
success = edit(state["collection"], place)
if success:
ui.remove_list_row(components["listbox"], place)
state["selected"] = None
else:
return
if success:
ui.add_list_row(
components["listbox"],
format_row(state["collection"][place], place + 1),
place
)
ui.clear_field(components["form_artist"])
ui.clear_field(components["form_album"])
ui.clear_field(components["form_no_tracks"])
ui.clear_field(components["form_length"])
ui.clear_field(components["form_year"])
ui.hide_subwindow(components["album_form"])
state["action"] = NOT_SELECTED
As seen here we chose to read the place from the state dictionary's "selected"
key
that was set when the form was opened. The edit itself is done by the edit function. If it reports a successful edit, the old row is removed from the box so that we can write the updated row in its place. Adding the row into the listbox and cleanup didn't change, so we did pretty well on that part. All that's left is changing the edit function.def read_form(album):
album["artist"] = ui.read_field(components["form_artist"])
album["album"] = ui.read_field(components["form_album"])
try:
album["no_tracks"] = int(ui.read_field(components["form_no_tracks"]))
except ValueError:
ui.open_msg_window("Error in data", "Number of tracks must be an integer", error=True)
return None
try:
album["length"] = check_length(ui.read_field(components["form_no_tracks"]))
except ValueError:
ui.open_msg_window("Error in data", "Length must be written as HH:MM:SS", error=True)
return None
try:
album["year"] = int(ui.read_field(components["form_year"]))
except ValueError:
ui.open_msg_window("Error in data", "Release year must be an integer", error=True)
return None
return album
def edit(collection, index):
album = read_form(collection[index].copy())
if album:
collection[index] = album
return True
return False
def add(collection):
album = read_form({})
if album:
collection.append(album)
return True
return False
Because both adding and editing need similar form reading, it was refactored into its own function. That's why we're also showing how the add function was changed from what it was. And that closes the chapter on our collection manager. Sorting features were left out from this version because we just wanted to show how to tie functions to interface elements, and how to pass data and state information between different parts of the program. The old sorting function was left in the code as an example, and it can be fairly easily converted to work with the new interface. One way is to make a button for each column that sorts the collection based on that column, and reverses the order if pressed again.
The final file that's been prettified a bit with Pylint (e.g. we removed unused variables from window creation because the buttons ended up not being referenced).
