From 382d0db2b9aaf35d3b35e09c83c6b3d325e6f8d8 Mon Sep 17 00:00:00 2001 From: waldek Date: Wed, 27 Apr 2022 11:19:31 +0200 Subject: [PATCH] splits gui to new file --- learning_python3_gui.md | 833 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 828 insertions(+), 5 deletions(-) diff --git a/learning_python3_gui.md b/learning_python3_gui.md index 83d7dee..5858827 100644 --- a/learning_python3_gui.md +++ b/learning_python3_gui.md @@ -1,6 +1,3 @@ -# WXpython - - # tkinter ## Tkinter helloworld @@ -967,9 +964,835 @@ if __name__ == "__main__": app.mainloop() ```` +## Coding challenge - Login generator with GUI + +## Coding challenge - Trivial pursuit with GUI + +# WXpython + +## wxpython helloworld + +The absolute most basic way to have a *hello world* GUI program up and running with wxpython is the following. + +```python +import wx + +app = wx.App() + +frame = wx.Frame(None, title="hello world") + +frame.Show() + +app.MainLoop() +``` + +While it works we know better by now. +We should include the `if __name__ == "__main__"` statement. + +```python +import wx +if __name__ == "__main__": + app = wx.App() + frame = wx.Frame(None, title="hello world") + frame.Show() + app.MainLoop() +``` + +The instance of `wx.App` is what will actually process our event loop and the `wx.Frame` is the content of our window. +In the latter we will create our button and labels so should create our own class and inherit from it. + +```python +import wx + + +class MainWindow(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="hello world") + self.Show() + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +We can add content to the *frame*, such as labels, input boxes and buttons as follows. +Note the first argument to the `wx.StaticText` creation. +This is *where* we put the label *into*. +It kind of works but we'll encounter a **problem** when we pack more visual **objects** into the same frame. + +```python +import wx + + +class MainWindow(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="hello world") + self.label = wx.StaticText(self, label="this is a label") + self.Show() + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +Let's try to put multiple visual object into the same frame. + +```python +import wx + + +class MainWindow(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="hello world") + self.label = wx.StaticText(self, label="this is a label") + self.input = wx.TextCtrl(self) + self.button = wx.Button(self) + self.Show() + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +You see how they are *stacked* one on top of the other? +We can overcome this problem with [sizers](https://wxpython.org/Phoenix/docs/html/sizers_overview.html). +There are multiple ones we can use but let's dive into a first one called a **box sizer**. + +```python +import wx + + +class MainWindow(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="hello world") + self.panel = wx.Panel(self) + self.box = wx.BoxSizer() + self.label = wx.StaticText(self.panel, label="this is a label") + self.input = wx.TextCtrl(self.panel) + self.button = wx.Button(self.panel, label="I'm clickable!") + self.box.Add(self.label) + self.box.Add(self.input) + self.box.Add(self.button) + self.panel.SetSizer(self.box) + self.Show() + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +This is looking better! +But it requires some explanation though. +Let's break it down. +The `wx.Frame` is your **window** in which you create a `wx.Panel` which is used to draw thing to. +To this panel you're adding three different *objects* (`wx.StaticText`, `wx.TextCtrl` and `wx.Button`) each with or without their own settings (such as a label or not). +Next you add these three objects to the `wx.BoxSizer` and you tell the panel to use this box as sizer. +It probably looks a bit convoluted but this is how most GUI libraries work internally. + +1. You create a frame +2. Within this frame you create a *drawing area* and set some form of automatic layout to it (the `wx.BoxSizer`). +3. You create the visual elements you want and add them one by one to the drawing area. +4. Success + +Now how do we link **user input** to **code actions**? +This is a complicated way of saying *actually do something when I click the damn button*! +For this we'll need to create a function, or better yet a method to the `wx.Frame` objects. +Each time we click the button, that method will be called. +Because it is a *method* it has access to *self* so it can modify *anything* within the scope of the instance. + +```python +import wx +import random + + +class MainWindow(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="hello world") + self.panel = wx.Panel(self) + self.box = wx.BoxSizer() + self.label = wx.StaticText(self.panel, label="this is a label") + self.input = wx.TextCtrl(self.panel) + self.button = wx.Button(self.panel, label="I'm clickable!") + self.button.Bind(wx.EVT_BUTTON, self.set_label_value) + self.box.Add(self.label) + self.box.Add(self.input) + self.box.Add(self.button) + self.panel.SetSizer(self.box) + self.Show() + + def set_label_value(self, event): + number = random.randint(0, 100) + self.label.SetLabel("{}".format(number)) + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +We can use the same *idea* to grab input from the textbox. + +```python +import wx + + +class MainWindow(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="hello world") + self.panel = wx.Panel(self) + self.box = wx.BoxSizer() + self.label = wx.StaticText(self.panel, label="this is a label") + self.input = wx.TextCtrl(self.panel) + self.button = wx.Button(self.panel, label="I'm clickable!") + self.button.Bind(wx.EVT_BUTTON, self.set_label_value) + self.box.Add(self.label) + self.box.Add(self.input) + self.box.Add(self.button) + self.panel.SetSizer(self.box) + self.Show() + + def set_label_value(self, event): + msg = self.input.GetValue() + self.label.SetLabel("{}".format(msg)) + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +## wxpython guess the number + +```python +import wx +import random + + +class MainWindow(wx.Frame): + def __init__(self): + wx.Frame.__init__(self, None, title="Guess the number") + self.number = random.randint(0, 100) + self.panel = wx.Panel(self) + self.box = wx.BoxSizer(wx.VERTICAL) + self.label = wx.StaticText(self.panel, label="I have a number in mind...") + self.input = wx.TextCtrl(self.panel) + self.button = wx.Button(self.panel, label="I'm clickable!") + self.button.Bind(wx.EVT_BUTTON, self.set_label_value) + self.box.Add(self.label) + self.box.Add(self.input) + self.box.Add(self.button) + self.panel.SetSizer(self.box) + self.Show() + + def set_label_value(self, event): + result = self.input.GetValue() + if result.isdigit(): + status, context = self.evaluate_user_number(int(result)) + self.label.SetLabel(context) + else: + self.label.SetLabel("I need numbers!") + + def evaluate_user_number(self, number): + if number > self.number: + return False, "my number is smaller" + elif number < self.number: + return False, "my number is bigger" + elif number == self.number: + return True, "You win!" + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +## MVC design pattern + +A simple console only MVC. +We'll add the GUI view in a bit. + +```python +import wx + + +class ConsoleView(object): + """A view for console.""" + + def select_task(self): + """Asks which index to look up.""" + idx = input("which task do you want to see? ") + return idx + + def show(self, task): + """Displays the task to the console. This method is called from the + controller.""" + print("your task: {}".format(task)) + + def error(self, msg): + """Prints error messages coming from the controller.""" + print("error: {}".format(msg)) + + +class WxView(wx.Dialog): + pass + + +class Model(object): + """The model houses add data and should implement all methods related to + adding, modifying and deleting tasks.""" + + db = ["go to the shops", "dryhop beer", "drop of motorbike"] + + def get_task(self, idx): + """Performs a task lookun into the database and returns it when found. + If no task is found, it returns an error message that will be displayed + in the view (via the controller).""" + try: + task = Model.db[idx] + except IndexError: + task = "task with {} not found!".format(idx) + return task + + +class Controller(object): + """Binds the model and the view together.""" + + def __init__(self, view): + self.model = Model() + self.view = view + + def run(self): + """The controller's main function. Depending on what type of view is + selected, a different event loop is setup. Do note that the ConsoleView + is not a real event loop, just a basic flow of action.""" + if self.view is ConsoleView: + self.view = self.view() + self._run_console_view() + elif self.view is WxView: + app = wx.App() + self.view = self.view() + self.view._set_controller(self) + app.MainLoop() + + def get_task_from_model(self, idx): + """Needed for the WxView to communicate with the controller.""" + task = self.model.get_task(idx) + self.view.show_task(task) + + def _run_console_view(self): + """Super simple event loop.""" + while True: + try: + idx = self.view.select_task() + idx = int(idx) + except Exception as e: + self.view.error(e) + continue + task = self.model.get_task(idx) + self.view.show(task) + + +if __name__ == "__main__": + view = ConsoleView + # view = WxView + app = Controller(view) + app.run() +``` + +And now with the implemented `WxView` class. + +```python +import wx + + +class ConsoleView(object): + """A view for console.""" + + def select_task(self): + """Asks which index to look up.""" + idx = input("which task do you want to see? ") + return idx + + def show(self, task): + """Displays the task to the console. This method is called from the + controller.""" + print("your task: {}".format(task)) + + def error(self, msg): + """Prints error messages coming from the controller.""" + print("error: {}".format(msg)) + + +class WxView(wx.Dialog): + """A view using a wx.Dialog window""" + + def __init__(self): + wx.Dialog.__init__(self, None, title="Task Manager") + self.panel = wx.Panel(self) + self.box = wx.BoxSizer(wx.VERTICAL) + self.task = wx.StaticText(self.panel, label="your task") + self.idx = wx.SpinCtrl(self.panel) + self.button = wx.Button(self.panel, label="submit") + self.button.Bind(wx.EVT_BUTTON, self.select_task) + self.box.Add(self.task, 0, wx.EXPAND, 1) + self.box.Add(self.idx, 0, wx.EXPAND, 1) + self.box.Add(self.button, 0, wx.EXPAND, 1) + self.panel.SetSizer(self.box) + self.Show() + + def _set_controller(self, controller): + """Set the controller so the view can communicate it's requests to it + and update it's values too.""" + self.controller = controller + + def select_task(self, event): + """Gets the index to look up in the model and submits the request to + the controller.""" + idx = self.idx.GetValue() + self.controller.get_task_from_model(idx) + + def show_task(self, task): + """Updates the visual label in the view with the task. This method is + called from the controller.""" + self.task.SetLabel(task) + + +class Model(object): + """The model houses add data and should implement all methods related to + adding, modifying and deleting tasks.""" + + db = ["go to the shops", "dryhop beer", "drop of motorbike"] + + def get_task(self, idx): + """Performs a task lookun into the database and returns it when found. + If no task is found, it returns an error message that will be displayed + in the view (via the controller).""" + try: + task = Model.db[idx] + except IndexError: + task = "task with {} not found!".format(idx) + return task + + +class Controller(object): + """Binds the model and the view together.""" + + def __init__(self, view): + self.model = Model() + self.view = view + + def run(self): + """The controller's main function. Depending on what type of view is + selected, a different event loop is setup. Do note that the ConsoleView + is not a real event loop, just a basic flow of action.""" + if self.view is ConsoleView: + self.view = self.view() + self._run_console_view() + elif self.view is WxView: + app = wx.App() + self.view = self.view() + self.view._set_controller(self) + app.MainLoop() + + def get_task_from_model(self, idx): + """Needed for the WxView to communicate with the controller.""" + task = self.model.get_task(idx) + self.view.show_task(task) + + def _run_console_view(self): + """Super simple event loop.""" + while True: + try: + idx = self.view.select_task() + idx = int(idx) + except Exception as e: + self.view.error(e) + continue + task = self.model.get_task(idx) + self.view.show(task) + + +if __name__ == "__main__": + view = WxView + app = Controller(view) + app.run() +``` + +For a GUI only login generator an implementation without MVC could look a bit like this. +Note that the actual calculation is done inside the window itself. +This is not a good idea because we should separate responsibilities into classes! + +```python +import wx +import login_generator + + +class MainWindow(wx.Dialog): + def __init__(self): + wx.Dialog.__init__(self, None, title="Login Generator") + self.full_window = wx.Panel(self) + self.full_window_sizer = wx.BoxSizer(wx.VERTICAL) + self.full_window_sizer.Add(self.create_top_panel(), 0, wx.EXPAND | wx.ALL, 20) + self.full_window_sizer.Add(self.create_bottom_panel(), 0, wx.EXPAND, 0) + self.full_window.SetSizer(self.full_window_sizer) + self.Show() + + def create_bottom_panel(self): + bottom_panel = wx.Panel(self.full_window) + bottom_panel_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.login_list = wx.ListCtrl(bottom_panel, style=wx.LC_REPORT) + self.login_list.Bind(wx.EVT_RIGHT_UP, self.show_popup) + self.login_list.InsertColumn(0, 'username', width=200) + self.login_list.InsertColumn(1, 'password', width=200) + bottom_panel_sizer.Add(self.login_list, 0, wx.EXPAND | wx.ALL, 200) + return bottom_panel + + def create_top_panel(self): + top_panel = wx.Panel(self.full_window) + top_panel_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.login_amount = wx.TextCtrl(top_panel) + self.login_complex = wx.CheckBox(top_panel, label="complex") + self.login_create = wx.Button(top_panel, label="Create") + self.login_create.Bind(wx.EVT_BUTTON, self.add_login) + top_panel_sizer.Add(self.login_amount, 1, wx.EXPAND|wx.ALL,0) + top_panel_sizer.Add(self.login_complex, 1, wx.EXPAND|wx.ALL,0) + top_panel_sizer.Add(self.login_create, 1, wx.EXPAND|wx.ALL,0) + top_panel.SetSizer(top_panel_sizer) + return top_panel + + def show_popup(self, event): + menu = wx.Menu() + menu.Append(1, "Copy selected items") + menu.Bind(wx.EVT_MENU, self.copy_items, id=1) + self.PopupMenu(menu) + + def copy_items(self, event): + selected_items = [] + for i in range(self.login_list.GetItemCount()): + if self.login_list.IsSelected(i): + username = selected_items.append( + self.login_list.GetItem(i, 0).GetText() + ) + password = selected_items.append( + self.login_list.GetItem(i, 1).GetText() + ) + clipdata = wx.TextDataObject() + clipdata.SetText("\n".join(selected_items)) + wx.TheClipboard.Open() + wx.TheClipboard.SetData(clipdata) + wx.TheClipboard.Close() + + def add_login(self, event): + amount = self.login_amount.GetValue() + complex = self.login_complex.GetValue() + try: + amount = int(amount) + except: + amount = 1 + for i in range(0, amount): + username = login_generator.generate_username() + password = login_generator.generate_password(12, complex) + index = self.login_list.InsertItem(0, username) + self.login_list.SetItem(index, 1, password) + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +Now let's assume the generate username and password function take some calculation time. +We'll add in a fake `time.sleep` to simulate. + +```python +import wx +import login_generator +import time + + +class MainWindow(wx.Dialog): + def __init__(self): + wx.Dialog.__init__(self, None, title="Login Generator") + self.full_window = wx.Panel(self) + self.full_window_sizer = wx.BoxSizer(wx.VERTICAL) + self.full_window_sizer.Add(self.create_top_panel(), 0, wx.EXPAND | wx.ALL, 20) + self.full_window_sizer.Add(self.create_bottom_panel(), 0, wx.EXPAND, 0) + self.full_window.SetSizer(self.full_window_sizer) + self.Show() + + def create_bottom_panel(self): + bottom_panel = wx.Panel(self.full_window) + bottom_panel_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.login_list = wx.ListCtrl(bottom_panel, style=wx.LC_REPORT) + self.login_list.Bind(wx.EVT_RIGHT_UP, self.show_popup) + self.login_list.InsertColumn(0, 'username', width=200) + self.login_list.InsertColumn(1, 'password', width=200) + bottom_panel_sizer.Add(self.login_list, 0, wx.EXPAND | wx.ALL, 200) + return bottom_panel + + def create_top_panel(self): + top_panel = wx.Panel(self.full_window) + top_panel_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.login_amount = wx.TextCtrl(top_panel) + self.login_complex = wx.CheckBox(top_panel, label="complex") + self.login_create = wx.Button(top_panel, label="Create") + self.login_create.Bind(wx.EVT_BUTTON, self.add_login) + top_panel_sizer.Add(self.login_amount, 1, wx.EXPAND|wx.ALL,0) + top_panel_sizer.Add(self.login_complex, 1, wx.EXPAND|wx.ALL,0) + top_panel_sizer.Add(self.login_create, 1, wx.EXPAND|wx.ALL,0) + top_panel.SetSizer(top_panel_sizer) + return top_panel + + def show_popup(self, event): + menu = wx.Menu() + menu.Append(1, "Copy selected items") + menu.Bind(wx.EVT_MENU, self.copy_items, id=1) + self.PopupMenu(menu) + + def copy_items(self, event): + selected_items = [] + for i in range(self.login_list.GetItemCount()): + if self.login_list.IsSelected(i): + username = selected_items.append( + self.login_list.GetItem(i, 0).GetText() + ) + password = selected_items.append( + self.login_list.GetItem(i, 1).GetText() + ) + clipdata = wx.TextDataObject() + clipdata.SetText("\n".join(selected_items)) + wx.TheClipboard.Open() + wx.TheClipboard.SetData(clipdata) + wx.TheClipboard.Close() + + def add_login(self, event): + amount = self.login_amount.GetValue() + complex_bool = self.login_complex.GetValue() + try: + amount = int(amount) + except: + amount = 1 + for i in range(0, amount): + username = login_generator.generate_username() + password = login_generator.generate_password(12, complex_bool) + time.sleep(1) + index = self.login_list.InsertItem(0, username) + self.login_list.SetItem(index, 1, password) + + +if __name__ == "__main__": + app = wx.App() + win = MainWindow() + app.MainLoop() +``` + +A clear separation of responsabilities can be acchieved via an MVC pattern and a login *library*. +The library code is quite straightforward and goes as follows. +It's basically the same code we did before but with added `try except` blocks. +Can you tell me why I added those? + +```python +import random +import string + + +def load_file(filename): + """ + We load a file and make a list out of it. Note that the same function is + used for both files (both adjectives and subjects). Functions should be + made as generic as possible. + + There IS a problem you can fix, some logins will have spaces in them. Try + to remove them in this function! + """ + words = [] + with open(filename, "r") as fp: + lines = fp.readlines() + for line in lines: + words.append(line.strip()) # what does strip() do, what does append() do? remember CTRL+Q! + return words + + +def generate_username(): + """ + We'll generate a random pair of adjectives and subjects from two wordlists. + You NEED to have both files in you python project for this to work! Note + the capitalize method call to make it all prettier... + """ + try: + adjectives = load_file("./adjectives.txt") + except: + adjectives = ["big", "funny", "normal", "red"] + try: + subjects = load_file("./subjects.txt") + except: + subjects = ["giraffe", "elephant", "cougar", "tiger"] + adjective = random.choice(adjectives) + subject = random.choice(subjects) + username = adjective.capitalize() + subject.capitalize() + return username + + +def generate_password(length=10, complictated=True): + """ + We generate a password with default settings. You can overide these by + changing the arguments in the function call. + """ + password = "" + if complictated: + chars = string.ascii_letters + string.digits + string.punctuation + else: + chars = string.ascii_letters + for i in range(0, length): + password += random.choice(chars) + return password + + +if __name__ == "__main__": + # let's do some testing! + username_test = generate_username() + print(username_test) + password_test = generate_password() + print(password_test) +``` + +And now the GUI code nicely split up in a **model**, **controller** and a **view**. +The overhead is quite large but it makes the code a lot more scalable. + +```python +import wx +import login_generator +import time +import threading +import queue + + +class View(wx.Dialog): + def __init__(self, controller): + wx.Dialog.__init__(self, None, title="Login Generator") + self.controller = controller + self.full_window = wx.Panel(self) + self.full_window_sizer = wx.BoxSizer(wx.VERTICAL) + self.full_window_sizer.Add(self.create_top_panel(), 0, wx.EXPAND | wx.ALL, 20) + self.full_window_sizer.Add(self.create_bottom_panel(), 0, wx.EXPAND, 0) + self.full_window.SetSizer(self.full_window_sizer) + self.Show() + + def create_bottom_panel(self): + bottom_panel = wx.Panel(self.full_window) + bottom_panel_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.login_list = wx.ListCtrl(bottom_panel, style=wx.LC_REPORT) + self.login_list.Bind(wx.EVT_RIGHT_UP, self.show_popup) + self.login_list.InsertColumn(0, 'username', width=200) + self.login_list.InsertColumn(1, 'password', width=200) + bottom_panel_sizer.Add(self.login_list, 0, wx.EXPAND | wx.ALL, 200) + return bottom_panel + + def create_top_panel(self): + top_panel = wx.Panel(self.full_window) + top_panel_sizer = wx.BoxSizer(wx.HORIZONTAL) + self.login_amount = wx.TextCtrl(top_panel) + self.login_complex = wx.CheckBox(top_panel, label="complex") + self.login_create = wx.Button(top_panel, label="Create") + self.login_create.Bind(wx.EVT_BUTTON, self.submit_request) + top_panel_sizer.Add(self.login_amount, 1, wx.EXPAND|wx.ALL,0) + top_panel_sizer.Add(self.login_complex, 1, wx.EXPAND|wx.ALL,0) + top_panel_sizer.Add(self.login_create, 1, wx.EXPAND|wx.ALL,0) + top_panel.SetSizer(top_panel_sizer) + return top_panel + + def show_popup(self, event): + menu = wx.Menu() + menu.Append(1, "Copy selected items") + menu.Bind(wx.EVT_MENU, self.copy_items, id=1) + self.PopupMenu(menu) + + def copy_items(self, event): + selected_items = [] + for i in range(self.login_list.GetItemCount()): + if self.login_list.IsSelected(i): + username = selected_items.append( + self.login_list.GetItem(i, 0).GetText() + ) + password = selected_items.append( + self.login_list.GetItem(i, 1).GetText() + ) + clipdata = wx.TextDataObject() + clipdata.SetText("\n".join(selected_items)) + wx.TheClipboard.Open() + wx.TheClipboard.SetData(clipdata) + wx.TheClipboard.Close() + + def submit_request(self, event): + amount = self.login_amount.GetValue() + complex_bool = self.login_complex.GetValue() + try: + amount = int(amount) + except: + amount = 1 + self.controller.get_new_logins(amount, complex_bool) + + def update_logins(self, login): + username, password = login + index = self.login_list.InsertItem(0, username) + self.login_list.SetItem(index, 1, password) + + +class Controller(object): + def __init__(self): + self.app = wx.App() + self.view = View(self) + self.model = Model(self) + + def get_new_logins(self, amount, complex_bool): + self.model.generate_login(amount, complex_bool) + + def set_new_logins(self, logins): + self.view.update_logins(logins) + + +class Model(threading.Thread): + def __init__(self, controller): + threading.Thread.__init__(self, target=self.main) + self.controller = controller + self.queue = queue.Queue() + self.start() + + def main(self): + while True: + amount, complex_bool = self.queue.get() + username = login_generator.generate_username() + password = login_generator.generate_password(12, complex_bool) + logins = (username, password) + self.controller.set_new_logins(logins) + time.sleep(1) + + def generate_login(self, amount, complex_bool): + for i in range(0, amount): + self.queue.put((amount, complex_bool)) + + +if __name__ == "__main__": + mvc = Controller() + mvc.app.MainLoop() +``` + +## Coding challenge - Login generator with GUI + +## Coding challenge - Trivial pursuit with GUI -# Coding challenge - Login generator with GUI -# Coding challenge - Trivial pursuit with GUI