Source code for joy.vui.display

# -*- coding: utf-8 -*-
#
#    Copyright © 2019 Simon Forman
#
#    This file is part of Thun
#
#    Thun is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    Thun is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with Thun.  If not see <http://www.gnu.org/licenses/>.
#
'''

Display
=================

This module implements a simple visual display system modeled on Oberon.

Refer to Chapter 4 of the Project Oberon book for more information.

There is a Display object that manages a pygame surface and N vertical
tracks each of which manages zero or more viewers.
'''
from copy import copy
from sys import stderr
from traceback import format_exc
import pygame
from .core import (
    open_viewer_on_string,
    GREY,
    MOUSE_EVENTS,
    )
from .viewer import Viewer
from joy.vui import text_viewer


[docs]class Display(object): ''' Manage tracks and viewers on a screen (Pygame surface.) The size and number of tracks are defined by passing in at least two ratios, e.g. Display(screen, 1, 4, 4) would create three tracks, one small one on the left and two larger ones of the same size, each four times wider than the left one. All tracks take up the whole height of the display screen. Tracks manage zero or more Viewers. When you "grow" a viewer a new track is created that overlays or hides one or two existing tracks, and when the last viewer in an overlay track is closed the track closes too and reveals the hidden tracks (and their viewers, if any.) In order to facilitate command underlining while mouse dragging the lookup parameter must be a function that accepts a string and returns a Boolean indicating whether that string is a valid Joy function name. Typically you pass in the __contains__ method of the Joy dict. This is a case of breaking "loose coupling" to gain efficiency, as otherwise we would have to e.g. send some sort of lookup message to the World context object, going through the whole Display.broadcast() machinery, etc. Not something you want to do on each MOUSEMOTION event. ''' def __init__(self, screen, lookup, *track_ratios): self.screen = screen self.w, self.h = screen.get_width(), screen.get_height() self.lookup = lookup self.focused_viewer = None self.tracks = [] # (x, track) self.handlers = [] # Non-viewers that should receive messages. # Create the tracks. if not track_ratios: track_ratios = 1, 4 x, total = 0, sum(track_ratios) for ratio in track_ratios[:-1]: track_width = self.w * ratio / total assert track_width >= 10 # minimum width 10 pixels self._open_track(x, track_width) x += track_width self._open_track(x, self.w - x) def _open_track(self, x, w): '''Helper function to create the pygame surface and Track.''' track_surface = self.screen.subsurface((x, 0, w, self.h)) self.tracks.append((x, Track(track_surface)))
[docs] def open_viewer(self, x, y, class_): ''' Open a viewer of class_ at the x, y location on the display, return the viewer. ''' track = self._track_at(x)[0] V = track.open_viewer(y, class_) V.focus(self) return V
[docs] def close_viewer(self, viewer): '''Close the viewer.''' for x, track in self.tracks: if track.close_viewer(viewer): if not track.viewers and track.hiding: i = self.tracks.index((x, track)) self.tracks[i:i + 1] = track.hiding assert sorted(self.tracks) == self.tracks for _, exposed_track in track.hiding: exposed_track.redraw() if viewer is self.focused_viewer: self.focused_viewer = None break
[docs] def change_viewer(self, viewer, y, relative=False): ''' Adjust the top of the viewer to a new y within the boundaries of its neighbors. If relative is False new_y should be in screen coords, else new_y should be relative to the top of the viewer. ''' for _, track in self.tracks: if track.change_viewer(viewer, y, relative): break
[docs] def grow_viewer(self, viewer): ''' Cause the viewer to take up its whole track or, if it does already, take up another track, up to the whole screen. This is the inverse of closing a viewer. "Growing" a viewer actually creates a new copy and a new track to hold it. The old tracks and viewers are retained, and they get restored when the covering track closes, which happens automatically when the last viewer in the covering track is closed. ''' for x, track in self.tracks: for _, V in track.viewers: if V is viewer: return self._grow_viewer(x, track, viewer)
def _grow_viewer(self, x, track, viewer): '''Helper function to "grow" a viewer.''' new_viewer = None if viewer.h < self.h: # replace the track with a new track that contains # a copy of the viewer at full height. new_track = Track(track.surface) # Reuse it, why not? new_viewer = copy(viewer) new_track._grow_by(new_viewer, 0, self.h - viewer.h) new_track.viewers.append((0, new_viewer)) new_track.hiding = [(x, track)] self.tracks[self.tracks.index((x, track))] = x, new_track elif viewer.w < self.w: # replace two tracks i = self.tracks.index((x, track)) try: # prefer the one on the right xx, xtrack = self.tracks[i + 1] except IndexError: i -= 1 # okay, the one on the left xx, xtrack = self.tracks[i] hiding = [(xx, xtrack), (x, track)] else: hiding = [(x, track), (xx, xtrack)] # We know there has to be at least one other track because it # there weren't then that implies that the one track takes up # the whole display screen (the only way you can get just one # track is by growing a viewer to cover the whole screen.) # Ergo, viewer.w == self.w, so this branch doesn't run. new_x = min(x, xx) new_w = track.w + xtrack.w r = new_x, 0, new_w, self.h new_track = Track(self.screen.subsurface(r)) new_viewer = copy(viewer) r = 0, 0, new_w, self.h new_viewer.resurface(new_track.surface.subsurface(r)) new_track.viewers.append((0, new_viewer)) new_track.hiding = hiding self.tracks[i:i + 2] = [(new_x, new_track)] new_viewer.draw() return new_viewer def _move_viewer(self, to, rel_y, viewer, _x, y): ''' Helper function to move (really copy) a viewer to a new location. ''' h = to.split(rel_y) new_viewer = copy(viewer) if not isinstance(to, Track): to = next(T for _, T in self.tracks for _, V in T.viewers if V is to) new_viewer.resurface(to.surface.subsurface((0, y, to.w, h))) to.viewers.append((y, new_viewer)) to.viewers.sort() # bisect.insort() would be overkill here. new_viewer.draw() self.close_viewer(viewer) def _track_at(self, x): ''' Return the track at x along with the track-relative x coordinate, raise ValueError if x is off-screen. ''' for track_x, track in self.tracks: if x < track_x + track.w: return track, x - track_x raise ValueError('x outside display: %r' % (x,))
[docs] def at(self, x, y): ''' Return the viewer (which can be a Track) at the x, y location, along with the relative-to-viewer-surface x and y coordinates. If there is no viewer at the location the Track will be returned instead. ''' track, x = self._track_at(x) viewer, y = track.viewer_at(y) return viewer, x, y
[docs] def iter_viewers(self): ''' Iterate through all viewers yielding (viewer, x, y) three-tuples. The x and y coordinates are screen pixels of the top-left corner of the viewer. ''' for x, T in self.tracks: for y, V in T.viewers: yield V, x, y
[docs] def done_resizing(self): ''' Helper method called directly by ``MenuViewer.mouse_up()`` to (hackily) update the display when done resizing a viewer. ''' for _, track in self.tracks: # This should be done by a Message? if track.resizing_viewer: track.resizing_viewer.draw() track.resizing_viewer = None break
[docs] def broadcast(self, message): ''' Broadcast a message to all viewers (except the sender) and all registered handlers. ''' for _, track in self.tracks: track.broadcast(message) for handler in self.handlers: handler(message)
[docs] def redraw(self): ''' Redraw all tracks (which will redraw all viewers.) ''' for _, track in self.tracks: track.redraw()
[docs] def focus(self, viewer): ''' Set system focus to a given viewer (or no viewer if a track.) ''' if isinstance(viewer, Track): if self.focused_viewer: self.focused_viewer.unfocus() self.focused_viewer = None elif viewer is not self.focused_viewer: if self.focused_viewer: self.focused_viewer.unfocus() self.focused_viewer = viewer viewer.focus(self)
[docs] def dispatch_event(self, event): ''' Display event handling. ''' try: if event.type in {pygame.KEYUP, pygame.KEYDOWN}: self._keyboard_event(event) elif event.type in MOUSE_EVENTS: self._mouse_event(event) else: print >> stderr, ( 'received event %s Use pygame.event.set_allowed().' % pygame.event.event_name(event.type) ) # Catch all exceptions and open a viewer. except: err = format_exc() print >> stderr, err # To be safe just print it right away. open_viewer_on_string(self, err, self.broadcast)
def _keyboard_event(self, event): if event.key == pygame.K_PAUSE and event.type == pygame.KEYUP: # At least on my keyboard the break/pause key sends K_PAUSE. # The main use of this is to open a TextViewer if you # accidentally close all the viewers, so you can recover. raise KeyboardInterrupt('break') if not self.focused_viewer: return if event.type == pygame.KEYUP: self.focused_viewer.key_up(self, event.key, event.mod) elif event.type == pygame.KEYDOWN: self.focused_viewer.key_down( self, event.unicode, event.key, event.mod) def _mouse_event(self, event): V, x, y = self.at(*event.pos) if event.type == pygame.MOUSEMOTION: if not isinstance(V, Track): V.mouse_motion(self, x, y, *(event.rel + event.buttons)) elif event.type == pygame.MOUSEBUTTONDOWN: if event.button == 1: self.focus(V) V.mouse_down(self, x, y, event.button) else: assert event.type == pygame.MOUSEBUTTONUP # Check for moving viewer. if (event.button == 2 and self.focused_viewer and V is not self.focused_viewer and V.MINIMUM_HEIGHT < y < V.h - self.focused_viewer.MINIMUM_HEIGHT ): self._move_viewer(V, y, self.focused_viewer, *event.pos) else: V.mouse_up(self, x, y, event.button)
[docs] def init_text(self, pt, x, y, filename): ''' Open and return a ``TextViewer`` on a given file (which must be present in the ``JOYHOME`` directory.) ''' viewer = self.open_viewer(x, y, text_viewer.TextViewer) viewer.content_id, viewer.lines = pt.open(filename) viewer.draw() return viewer
[docs]class Track(Viewer): ''' Manage a vertical strip of the display, and the viewers on it. ''' def __init__(self, surface): Viewer.__init__(self, surface) self.viewers = [] # (y, viewer) self.hiding = None self.resizing_viewer = None self.draw()
[docs] def split(self, y): ''' Split the Track at the y coordinate and return the height available for a new viewer. Tracks manage a vertical strip of the display screen so they don't resize their surface when split. ''' h = self.viewers[0][0] if self.viewers else self.h assert h > y return h - y
[docs] def draw(self, rect=None): '''Draw the track onto its surface, clearing all content. If rect is passed only draw to that area. This supports e.g. closing a viewer that then exposes part of the track. ''' self.surface.fill(GREY, rect=rect)
[docs] def viewer_at(self, y): ''' Return the viewer at y along with the viewer-relative y coordinate, if there's no viewer at y return this track and y. ''' for viewer_y, viewer in self.viewers: if viewer_y < y <= viewer_y + viewer.h: return viewer, y - viewer_y return self, y
[docs] def open_viewer(self, y, class_): '''Open and return a viewer of class at y.''' # Todo: if y coincides with some other viewer's y replace it. viewer, viewer_y = self.viewer_at(y) h = viewer.split(viewer_y) new_viewer = class_(self.surface.subsurface((0, y, self.w, h))) new_viewer.draw() self.viewers.append((y, new_viewer)) self.viewers.sort() # Could use bisect module but how many # viewers will you ever have? return new_viewer
[docs] def close_viewer(self, viewer): '''Close the viewer, reuse the freed space.''' for y, V in self.viewers: if V is viewer: self._close_viewer(y, V) return True return False
def _close_viewer(self, y, viewer): '''Helper function to do the actual closing.''' i = self.viewers.index((y, viewer)) del self.viewers[i] if i: # The previous viewer gets the space. previous_y, previous_viewer = self.viewers[i - 1] self._grow_by(previous_viewer, previous_y, viewer.h) else: # This track gets the space. self.draw((0, y, self.w, viewer.surface.get_height())) viewer.close() def _grow_by(self, viewer, y, h): '''Grow a viewer (located at y) by height h. This might seem like it should be a method of the viewer, but the viewer knows nothing of its own y location on the screen nor the parent track's surface (to make a new subsurface) so it has to be a method of the track, which has both. ''' h = viewer.surface.get_height() + h try: surface = self.surface.subsurface((0, y, self.w, h)) except ValueError: # subsurface rectangle outside surface area pass else: viewer.resurface(surface) if h <= viewer.last_touch[1]: viewer.last_touch = 0, 0 viewer.draw()
[docs] def change_viewer(self, viewer, new_y, relative=False): ''' Adjust the top of the viewer to a new y within the boundaries of its neighbors. If relative is False new_y should be in screen coords, else new_y should be relative to the top of the viewer. ''' for old_y, V in self.viewers: if V is viewer: if relative: new_y += old_y if new_y != old_y: self._change_viewer(new_y, old_y, V) return True return False
def _change_viewer(self, new_y, old_y, viewer): new_y = max(0, min(self.h, new_y)) i = self.viewers.index((old_y, viewer)) if new_y < old_y: # Enlarge self, shrink upper neighbor. if i: previous_y, previous_viewer = self.viewers[i - 1] if new_y - previous_y < self.MINIMUM_HEIGHT: return previous_viewer.resizing = 1 h = previous_viewer.split(new_y - previous_y) previous_viewer.resizing = 0 self.resizing_viewer = previous_viewer else: h = old_y - new_y self._grow_by(viewer, new_y, h) else: # Shink self, enlarge upper neighbor. # Enforce invariant. try: h, _ = self.viewers[i + 1] except IndexError: # No next viewer. h = self.h if h - new_y < self.MINIMUM_HEIGHT: return # Change the viewer and adjust the upper viewer or track. h = new_y - old_y self._grow_by(viewer, new_y, -h) # grow by negative height! if i: previous_y, previous_viewer = self.viewers[i - 1] previous_viewer.resizing = 1 self._grow_by(previous_viewer, previous_y, h) previous_viewer.resizing = 0 self.resizing_viewer = previous_viewer else: self.draw((0, old_y, self.w, h)) self.viewers[i] = new_y, viewer # self.viewers.sort() # Not necessary, invariant holds. assert sorted(self.viewers) == self.viewers
[docs] def broadcast(self, message): ''' Broadcast a message to all viewers on this track (except the sender.) ''' for _, viewer in self.viewers: if viewer is not message.sender: viewer.handle(message)
[docs] def redraw(self): '''Redraw the track and all of its viewers.''' self.draw() for _, viewer in self.viewers: viewer.draw()