Grafiske brukergrensesnitt



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 rammeverket tkinter 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)
Illustrasjon av programmet over
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:

Prinsippet om model-view-controller

I vårt MVC-baserte rammeverk uib_inf100_graphics må koden vi skriver forholde seg til følgende kjøreregler:

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)
Illustrasjon av programmet over
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)
Illustrasjon av programmet over
# 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 kalles timer_fired, og umiddelbart etter kallet er ferdig, kalles redraw_all. Når kallet til redraw_all er ferdig, venter timeren i app.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:

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)
Illustrasjon av programmet over
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)
Illustrasjon av programmet over
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)
Illustrasjon av programmet over
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)
Illustrasjon av programmet over
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)
Illustrasjon av programmet over