Grafiske brukergrensesnitt
- Første eksempel: tell antall tastetrykk
- Model-View-Controller
- Identifisering av tastetrykk
- Flytte en prikk med piltastene
- Flytte en prikk med museklikk
- Flytte en prikk med timer
- Endre hastighet for timer
- Sette timer på pause
- Brudd med MVC
- Bilder
- Eksempel: legg til og fjern prikker
- Eksempel: sprettende figur
- Eksempel: museklikk i rutenett
- Eksempel: knapper
Et grafisk brukergrensesnitt er et dataprogram som lar brukeren interagere med programmet ved å klikke på knapper, benytte tastaturet, flytte på musen eller andre handlinger uten å primært forholde seg til terminalen.
Det finnes flere ulike rammeverk som tilbyr funksjonalitet for å lage grafiske brukergrensesnitt. I dette emnet skal vi benytte oss av
uib_inf100_graphics
, som er en forenklet versjon av rammeverkettkinter
som er en del av standardbiblioteket i Python. Om du har fulgt emnet har du allerede installert rammeverket på din datamaskin (se kursnotatene om grafikk for instruksjoner om installasjon).Vi beveger oss nå videre fra subpakken «simple» som vi lærte om tidligere, og skal i stedet benytte subpakken «event_app» som gir oss mulighet til å lage interaktive grafiske applikasjoner. Selve funksjonene for å tegne på canvas vil fungere på samme måte som før; forskjellen er at vi nå også kan skrive kode som reagerer på brukerens handlinger.
Første eksempel: tell antall tastetrykk
from uib_inf100_graphics.event_app import run_app
def app_started(app):
# app_started: kjøres én gang når programmet starter.
# Her oppretter vi variabler i `app`` og gir dem initiell verdi.
app.counter = 0
def key_pressed(app, event):
# key_pressed: kjøres hver gang en tast trykkes.
# Vi kan endre variabler i `app` her.
app.counter += 1
def redraw_all(app, canvas):
# redraw_all: kode for å tegne noe på skjermen. Kjøres vanligvis
# flere ganger i sekundet.
# Vi kan benytte (se på) variablene i `app` her, men ikke endre dem.
canvas.create_text(
app.width/2, app.height/2,
text=f'{app.counter} tastetrykk',
font='Arial 30 bold'
)
run_app(width=300, height=100)
Model-View-Controller
Når man skriver programmer med grafiske brukergrensesnitt, kan koden fort bli rotete og uoversiktelig. For å hjelpe oss å skrive oversiktelig kode det er mulig å feilsøke, benytter vi oss av et prinssipp som kalles model-view-controller (MVC). I dette paradigmet er det tre sentrale begreper:
- Modell. En modell er en samling med variabler og data som representerer tilstanden til programmet. I eksempelet over er objektet
app
modellen, og funksjonenapp_started
er ansvarlig for å opprette variablene i den. - Visning. Funksjoner for å tegne noe på skjermen, fortrinnsvis basert på variablene og dataen i modellen. I eksempelet over er det funksjonen
redraw_all
som er visningen. - Kontroller. Funksjoner som responderer på tastetrykk, museklikk, klokkeslag/timer eller andre hendelser og oppdaterer modellen på bakgrunn av dette. I eksempelet over er funksjonen
key_pressed
en kontroller, men det finnes også mange andre (for eksempel mouse_pressed og timer_fired som introduseres litt senere).
I vårt MVC-baserte rammeverk uib_inf100_graphics
må koden vi skriver forholde seg til følgende kjøreregler:
- Aldri gjør et kall til en kontroller-funksjon (f. eks. key_pressed, mouse_pressed, timer_fired) eller til redraw_all på egen hånd. Rammeverket gjør dette for deg automatisk. I eksempelet over, legg merke til at det eneste funksjonskallet vi gjør selv er til
run_app
. - Kontroller-funksjonene skal kun oppdatere modellen (app), de skal ikke oppdatere visningen.
- Visningen skal kun tegne ting på skjermen, den skal ikke endre på noe i modellen (app).
- Variabler i modellen (app) opprettes første gang i funksjonen app_started.
Dersom du bryter noen av disse reglene, kalles det brudd med MVC (engelsk: MVC violation). Hvis rammeverket vårt oppdager et slikt brudd, vil det umiddelbart stoppe programmet og vise en feilmelding.
PS: Bruk av rammeverket vårt hjelper deg å følge MVC, men gir ingen garantier. Med andre ord, det er fullt mulig å bryte MVC med vårt rammeverk uten å få en feilmelding. Å bryte MVC er dårlig stil og gjør koden din uoversiktelig og vanskelig å feilsøke; så ikke gjør det selv om det teknisk sett er mulig.
Identifisering av tastetrykk
Tastetrykk kan være forskjellige. Vi kan se hvilken tast som ble trykket ved å se på verdien event.key
som er en streng som beskriver tasten. Under er et program som lar oss se hvilken streng dette er når vi trykker på en tast.
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.message = 'Press any key'
def key_pressed(app, event):
app.message = f"event.key == '{event.key}'"
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 40, text=app.message,
font='Arial 20 bold')
key_names_text = '''\
Here are the legal event.key names:
* Keyboard key labels (letters, digits, punctuation)
* Arrow directions ('Up', 'Down', 'Left', 'Right')
* Whitespace ('Space', 'Enter', 'Tab', 'BackSpace')
* Other commands ('Delete', 'Escape')'''
canvas.create_text(
app.width/2, 80,
text=key_names_text,
anchor="n",
font='Arial 16'
)
run_app(width=500, height=250)
Flytte en prikk med piltastene
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'):
app.cx -= 10
elif (event.key == 'Right'):
app.cx += 10
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
# I denne versjonen kan ikke ballen flytte seg ut av lerretet
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'):
app.cx -= 10
if (app.cx - app.r < 0):
app.cx = app.r
elif (event.key == 'Right'):
app.cx += 10
if (app.cx + app.r > app.width):
app.cx = app.width - app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre')
canvas.create_text(app.width/2, 40,
text='Ballen kan ikke kan flytte seg ut av vinduet')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
# I denne versjonen kommer ballen tilbake på motsatt side
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
elif (event.key == 'Right'):
app.cx += 10
if (app.cx - app.r >= app.width):
app.cx = 0 - app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre')
canvas.create_text(app.width/2, 40,
text='Ballen kommer rundt på motsatt side')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
# I denne versjonen kan ballen bevege seg i to dimensjoner
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def key_pressed(app, event):
if (event.key == 'Left'): app.cx -= 10
elif (event.key == 'Right'): app.cx += 10
elif (event.key == 'Up'): app.cy -= 10
elif (event.key == 'Down'): app.cy += 10
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt med piltaster høyre/venstre/opp/ned')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
Flytte en prikk med museklikk
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def mouse_pressed(app, event):
app.cx = event.x
app.cy = event.y
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Flytt ved å klikke med musen')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
Flytte en prikk med timer
Funksjonen timer_fired
demonstrert her regnes som en kontroller, selv om det ikke strengt tatt er brukererens handling som gjør at metoden kalles; i stedet er det rammeverket uib_inf100_graphics selv som «opptrer som en bruker» ved å periodisk kalle denne funksjonen med et fast intervall.
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2
app.r = 40
def timer_fired(app):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Prikken flytter seg automatisk')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
Endre hastighet for timer
Som standard kalles funksjonen timer_fired
med et intervall på 100 millisekunder (dvs. 10 ganger i sekundet). Vi kan endre dette ved å endre på variabelen app.timer_delay
. Vi kan endre variabelens verdi i app_started
eller (for å dynamisk endre hastigheten) i en kontroller-funksjon.
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.timer_delay = 128 # milliseconds
app.cx = app.width/2
app.cy = app.height/2 + 15
app.r = 40
def key_pressed(app, event):
if event.key == "Up":
app.timer_delay *= 2
app.timer_delay = max(app.timer_delay, 1)
elif event.key == "Down":
app.timer_delay //= 2
def timer_fired(app):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text=f"{app.timer_delay=}")
canvas.create_text(app.width/2, 40,
text=f"Trykk pil opp/ned for å doble/halvere delay")
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
Legg merke til at hastigheten på animasjonen ikke endres vesentlig når vi kommer ned til delay-verdier i nærheten av 0. Det er fordi det på en vanlig datamaskin med moderne spesifikasjoner fremdeles tar et par millisekunder å faktisk tegne skjermbildet, og da betyr ventetiden vi har mellom hvert kall mindre og mindre.
Timeren i
uib_inf100_graphics
fungerer omtrent slik: først kallestimer_fired
, og umiddelbart etter kallet er ferdig, kallesredraw_all
. Når kallet til redraw_all er ferdig, venter timeren iapp.timer_delay
millisekunder, og begynner deretter på nytt. Tiden det tar mellom hvert nye kall til timer_fired kan derfor grovt sett regnes ut som
- tiden det tar å kalle timer_fired, pluss
- tiden det tar å kalle redraw_all, pluss
- antall millisekunder definert i app.timer_delay.
Ventetiden kan også påvirkes av andre forhold, slik som prosessorbelastningen din datamaskin er utsatt for av andre kontroller-funksjoner eller til og med av andre programmer som kjøres samtidig på datamaskinen.
Sette timer på pause
Nyttig for feilsøking av animasjoner!
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.cx = app.width/2
app.cy = app.height/2 + 15
app.r = 40
app.paused = False
def timer_fired(app):
if not app.paused:
do_step(app)
def do_step(app):
app.cx -= 10
if (app.cx + app.r <= 0):
app.cx = app.width + app.r
def key_pressed(app, event):
if event.key == 'p':
app.paused = not app.paused
elif event.key == 'Space' and app.paused:
do_step(app)
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Prikken flytter seg automatisk')
canvas.create_text(app.width/2, 40,
text='Trykk p for å sette på pause')
canvas.create_text(app.width/2, 60,
text='Trykk mellomrom for å ta steg i pausen')
canvas.create_oval(app.cx-app.r, app.cy-app.r,
app.cx+app.r, app.cy+app.r,
fill='lightblue')
run_app(width=300, height=200)
Brudd med MVC
Vi kan ikke endre modellen i redraw_all
.
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.x = 42
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Et brudd med MVC')
app.x = 10 # Her er bruddet! Ikke lov å endre modellen i visningen
run_app(width=300, height=200)
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.x = [42, 43]
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Også et brudd med MVC')
app.x[0] = 99 # Her er bruddet! Ikke lov å mutere noe i modellen her
run_app(width=300, height=200)
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.x = [42, 43]
def key_pressed(app, event):
mutany(app) # Det er ikke MVC-brudd når mutany kalles fra en kontroller
def mutany(app):
app.x.append(42) # Her skjer selve MVC-bruddet
def redraw_all(app, canvas):
canvas.create_text(app.width/2, 20,
text='Enda et brudd med MVC!')
canvas.create_text(app.width/2, 40,
text='Trykk på en tast et par ganger')
canvas.create_text(app.width/2, 60,
text=f'{app.x=}')
if len(app.x) == 5:
mutany(app) # Under dette kallet skjer det et MVC-brudd
run_app(width=300, height=200)
Legg merke til at feilmeldingen (se under) ikke spesifiserer i hvilken funksjon bruddet skjer (nemlig i mutany -funksjonen), bare at det skjedde under utførelsen av redraw_all. Når du får en slik feilmelding, må du altså også undersøke at ingen av hjelpefunksjonene som benyttes muterer modellen.
Traceback (most recent call last):
No traceback available. Error occurred in redraw_all.
Exception: MVC Violation: you may not change the app state (the model) in redraw_all (the view)
Bilder
Du kan benytte alle de samme metodene og hjelpefunksjonene for å laste og vise bilder som vi kjenner fra kursnotatene om grafikk, men:
- Ikke last inn bildet i redraw_all. Dette vil føre til at bildet lastes inn på nytt hver gang skjermen tegnes på nytt; fordi lasting av bilder (både fra fil og fra internett) er svært tidkrevende, vil dette føre til at programmet ditt blir tregt og lite responsivt.
I stedet bør du laste inn alle bildene du trenger i app_started. Da vil bildene lastes inn én gang, og deretter kan du bruke dem som du vil i redraw_all.
from uib_inf100_graphics.event_app import run_app
from uib_inf100_graphics.helpers import load_image_http
def app_started(app):
# Laster alle bilder vi kan tenke oss å bruke
app.image_yellow = load_image_http('https://tinyurl.com/inf100yellowghost')
app.image_black = load_image_http('https://tinyurl.com/inf100blackghost')
# Selve modellen
app.use_yellow_image = True
def key_pressed(app, event):
app.use_yellow_image = not app.use_yellow_image
def redraw_all(app, canvas):
image = app.image_yellow if app.use_yellow_image else app.image_black
canvas.create_image(
app.width / 2,
app.height / 2,
pil_image=image,
)
run_app(width=400, height=400)
Eksempel: legg til og fjern prikker
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.circle_centers = [ ]
def mouse_pressed(app, event):
new_circle_center = (event.x, event.y)
app.circle_centers.append(new_circle_center)
def key_pressed(app, event):
if (event.key == 'd'):
if (len(app.circle_centers) > 0):
app.circle_centers.pop(0)
else:
print('Ingen flere prikker å fjerne!')
def redraw_all(app, canvas):
# tegn prikkene
for circle_center in app.circle_centers:
(cx, cy) = circle_center
r = 20
canvas.create_oval(cx-r, cy-r, cx+r, cy+r, fill='cyan')
# tegn teksten
canvas.create_text(app.width/2, 20,
text='Eksempel: legg til og fjern prikker')
canvas.create_text(app.width/2, 40,
text='Museklikk oppretter prikker')
canvas.create_text(app.width/2, 60,
text='Trykk på "d" for å fjerne prikker')
run_app(width=400, height=400)
Eksempel: sprettende figur
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.square_left = app.width//2
app.square_top = app.height//2
app.square_size = 25
app.dx = -4
app.dy = 5
app.is_paused = False
app.timer_delay = 25 # millisekunder
def key_pressed(app, event):
if event.key == "p":
app.is_paused = not app.is_paused
elif event.key == "s":
do_step(app)
def timer_fired(app):
if not app.is_paused:
do_step(app)
def do_step(app):
# Flytt horisontalt
app.square_left += app.dx
# Sjekk om firkanten har gått utenfor lerretet, og hvis ja, snu
# retning; men flytt også firkanten til kanten (i stedet for å gå
# forbi). Merk: det finnes andre, mer sofistikerte måter å håndtere
# at rektangelet går forbi kanten...
if app.square_left < 0:
# snu retningen!
app.square_left = 0
app.dx = -app.dx
elif app.square_left > app.width - app.square_size:
app.square_left = app.width - app.square_size
app.dx = -app.dx
# Flytt vertikalt på samme måte
app.square_top += app.dy
if app.square_top < 0:
# snu retningen!
app.square_top = 0
app.dy = -app.dy
elif app.square_top > app.height - app.square_size:
app.square_top = app.height - app.square_size
app.dy = -app.dy
def redraw_all(app, canvas):
# tegn firkanten
canvas.create_rectangle(
app.square_left,
app.square_top,
app.square_left + app.square_size,
app.square_top + app.square_size,
fill="yellow",
)
# tegn teksten
canvas.create_text(
app.width/2, 20,
text="Trykk 'p' for å sette på pause",
)
canvas.create_text(
app.width/2, 40,
text="Trykk 's' for å gjør et enkelt steg",
)
run_app(width=400, height=150)
Eksempel: museklikk i rutenett
from uib_inf100_graphics.event_app import run_app
def app_started(app):
app.rows = 5
app.cols = 8
app.margin = 50 # margin rundt rutenettet
app.selection = (-1, -1) # (row, col) for valgt rute, (-1,-1) for ingen
def point_in_grid(app, x, y):
# returner True hvis piksel-koordinatet (x, y) er på innsiden av
# rutenettet slik det blir tegnet i visningen.
return ((app.margin <= x <= app.width-app.margin) and
(app.margin <= y <= app.height-app.margin))
def get_cell(app, x, y):
# "visning-til-modell"
# returnerer (row, col) for ruten hvor piksel-koordnatet (x, y) hører
# hjemme, eller (-1, -1) hvis koodinatet er utenfor rutenettet
if (not point_in_grid(app, x, y)):
return (-1, -1)
grid_width = app.width - 2*app.margin
grid_height = app.height - 2*app.margin
cell_width = grid_width / app.cols
cell_height = grid_height / app.rows
# Merk: vi trenger å konvertere til int her; det er ikke
# tilstrekkelig å benytte //, siden x, y, eller app.margin kan
# være flyttall, og da vil også // returnere flyttall
row = int((y - app.margin) / cell_height)
col = int((x - app.margin) / cell_width)
return (row, col)
def get_cell_bounds(app, row, col):
# "modell-til-visning"
# returnerer (x0, y0, x1, y1), piksel-koordinater for hjørnene til
# den gitte ruten
grid_width = app.width - 2*app.margin
grid_height = app.height - 2*app.margin
column_width = grid_width / app.cols
row_height = grid_height / app.rows
x0 = app.margin + col * column_width
x1 = app.margin + (col+1) * column_width
y0 = app.margin + row * row_height
y1 = app.margin + (row+1) * row_height
return (x0, y0, x1, y1)
def mouse_pressed(app, event):
(row, col) = get_cell(app, event.x, event.y)
# velg denne ruten med mindre den allerede er valgt
if (app.selection == (row, col)):
app.selection = (-1, -1)
else:
app.selection = (row, col)
def redraw_all(app, canvas):
# tegn alle rutene
for row in range(app.rows):
for col in range(app.cols):
(x0, y0, x1, y1) = get_cell_bounds(app, row, col)
fill = "orange" if (app.selection == (row, col)) else "cyan"
canvas.create_rectangle(x0, y0, x1, y1, fill=fill)
canvas.create_text(app.width/2, app.height/2, text="Klikk på rutene!",
font="Arial 20 bold", fill="darkBlue")
run_app(width=400, height=300)
Eksempel: knapper
from uib_inf100_graphics.event_app import run_app
##############
## Modellen ##
##############
def app_started(app):
app.count = 0
app.buttons = [
# [x1, y1, x2, y2, "Navn på knapp", funksjon]
[30, 30, 130, 60, "Opp", increase],
[150, 30, 250, 60, "Ned", decrease]
]
#################
## Kontrollere ##
#################
def increase(app):
app.count += 1
def decrease(app):
app.count -= 1
def point_in_rectangle(x1, y1, x2, y2, x, y):
return (min(x1, x2) <= x <= max(x1, x2)
and min(y1, y2) <= y <= max(y1, y2))
def execute_button_action_if_clicked(app, button, mouse_x, mouse_y):
x1, y1, x2, y2, label, func = button
if point_in_rectangle(x1, y1, x2, y2, mouse_x, mouse_y):
func(app)
def mouse_pressed(app, event):
for button in app.buttons:
execute_button_action_if_clicked(app, button, event.x, event.y)
#############
## Visning ##
#############
def redraw_all(app, canvas):
# tegn knappene
for button in app.buttons:
draw_button(canvas, button)
# tegn telleren
canvas.create_text(app.width/2, app.height*2/3, text=f"{app.count}",
font="Arial 20")
def draw_button(canvas, button):
x1, y1, x2, y2, label, func = button
canvas.create_rectangle(x1, y1, x2, y2, fill="lightgray")
mid_x = (x1 + x2) / 2
mid_y = (y1 + y2) / 2
canvas.create_text(mid_x, mid_y, text=label)
#####################
## Kjør programmet ##
#####################
run_app(width=280, height=140)