511 lines
19 KiB
Python
511 lines
19 KiB
Python
# -*- 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 __future__ import print_function
|
|
from __future__ import division
|
|
from builtins import next, object
|
|
from past.utils import old_div
|
|
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
|
|
|
|
|
|
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 = old_div(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)))
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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,))
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
def redraw(self):
|
|
'''
|
|
Redraw all tracks (which will redraw all viewers.)
|
|
'''
|
|
for _, track in self.tracks:
|
|
track.redraw()
|
|
|
|
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)
|
|
|
|
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((
|
|
'received event %s Use pygame.event.set_allowed().'
|
|
% pygame.event.event_name(event.type)
|
|
), file=stderr)
|
|
# Catch all exceptions and open a viewer.
|
|
except:
|
|
err = format_exc()
|
|
print(err, file=stderr) # 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)
|
|
# This is not UnicodeType. TODO does this need to be fixed?
|
|
# self, event.str, 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)
|
|
|
|
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
|
|
|
|
|
|
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()
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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()
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
def redraw(self):
|
|
'''Redraw the track and all of its viewers.'''
|
|
self.draw()
|
|
for _, viewer in self.viewers:
|
|
viewer.draw()
|