PyQt5チュートリアルのテトリスをMVC化

[ZetCode](http://zetcode.com/)の[PyQt5チュートリアル](http://zetcode.com/gui/pyqt5/)にある[テトリスのソースコード](http://zetcode.com/gui/pyqt5/tetris/)を拝見したのですが、モデルが分離されておらず、気になってしまったので MVC(Model-View-Controller) 化に挑戦しました。

* Model(データ) は表示に左右されない部分、PyQt5(GUI)は登場しない
* Modelは大きく Tetrimino と Field の2つで構成、[Facadeパターン](https://ja.wikipedia.org/wiki/Facade_%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3)でModelに統合
* View(表示) と Controller(操作) で PyQt5を使用
* チュートリアルではwidgetを継承して使用しているが、名前衝突を嫌って継承しないように変更

改善点などありましたらコメントお願いします。
何か参考になることがあれば幸いです。

```py:tetrimino.py
class Tetrimino:
    """
    # python -m doctest -v tetrimino.py
    >>> len(tetriminos)
    7
    >>> print(tetrimino_names['Z'].rotate_unclockwise())
    ## 
     ##
    >>> print(tetrimino_names['S'].rotate_clockwise())
     ##
    ## 
    >>> print(tetrimino_names['I'].rotate_unclockwise())
    ####
    >>> print(tetrimino_names['T'])
    ###
     # 
    >>> print(tetrimino_names['O'])
    ##
    ##
    >>> tetrimino_names['O'] is tetrimino_names['O'].rotate_clockwise()
    True
    >>> print(tetrimino_names['L'].rotate_clockwise().rotate_clockwise())
    # 
    # 
    ##
    >>> print(tetrimino_names['J'].rotate_unclockwise().rotate_unclockwise())
     #
     #
    ##
    """

    def __init__(self, name, *offsets, rotate=True):
        self.name = name
        self.offsets = offsets
        self.can_rotate = rotate
        self.min_x = min(x for x, y in self)
        self.min_y = min(y for x, y in self)
        self.max_x = max(x for x, y in self)
        self.max_y = max(y for x, y in self)

    def __iter__(self):
        return iter((x, y) for x, y in self.offsets)

    def __str__(self):
        xs = range(self.min_x, self.max_x + 1)
        ys = range(self.min_y, self.max_y + 1)
        return '\n'.join(''.join(' #'[(x, y) in self.offsets] for x in xs)
                         for y in ys)

    def move(x, y):
        return Tetrimino(self.name, *self.offsets, rotate=self.can_rotate)

    def rotate_unclockwise(self):
        if not self.can_rotate:
            return self
        return Tetrimino(self.name, *((y, -x) for x, y in self.offsets))

    def rotate_clockwise(self):
        if not self.can_rotate:
            return self
        return Tetrimino(self.name, *((-y, x) for x, y in self.offsets))


tetriminos = (  # (+0, +0) is rotation center
    Tetrimino('Z', (+0, -1), (+0, +0), (-1, +0), (-1, +1)),
    Tetrimino('S', (+0, -1), (+0, +0), (+1, +0), (+1, +1)),
    Tetrimino('I', (+0, -1), (+0, +0), (+0, +1), (+0, +2)),
    Tetrimino('T', (-1, +0), (+0, +0), (+1, +0), (+0, +1)),
    Tetrimino('O', (+0, +0), (+1, +0), (+0, +1), (+1, +1), rotate=False),
    Tetrimino('L', (-1, -1), (+0, -1), (+0, +0), (+0, +1)),
    Tetrimino('J', (+1, -1), (+0, -1), (+0, +0), (+0, +1)),
)

tetrimino_names = {tetrimino.name: tetrimino for tetrimino in tetriminos}
```

```py:field.py
class TetrisField:
    """
    # python -m doctest -v field.py
    >>> field = TetrisField(6, 4)
    >>> print(field)
    |      |
    |      |
    |      |
    |      |
    >>> field.put(((-1, -1), (0, -1), (-1, 0), (0, 0)), 1, 3)
    >>> print(field)
    |      |
    |      |
    |##    |
    |##    |
    >>> field.clear_complete_lines()
    0
    >>> field.can_put(((0, 0), (1, 0), (2, 0), (3, 0)), 2, 3)
    True
    >>> field.put(((0, 0), (1, 0), (2, 0), (3, 0)), 2, 3)
    >>> print(field)
    |      |
    |      |
    |##    |
    |######|
    >>> field.clear_complete_lines()
    1
    >>> print(field)
    |      |
    |      |
    |      |
    |##    |
    """

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.tiles = [[None] * width for y in range(height)]
        self.wall = ((-width - 1, -height - 1), (width + 1, height + 1))


    def __str__(self):
        return '\n'.join('|' + ''.join('# '[tile is None] for tile in row) + '|'
                         for row in self.tiles)

    def __iter__(self):
        return iter(self.tiles)

    def __setitem__(self, coodinates, tile):
        x, y = coodinates
        if 0 <= x < self.width and 0 <= y < self.height:
            self.tiles[y][x] = tile

    def __getitem__(self, y_or_xy):
        if isinstance(y_or_xy, int):
            y = y_or_xy
            return self.tiles[y] if 0 <= y < self.height else ()
        x, y = y_or_xy
        if y < 0:
            return None
        elif 0 <= x < self.width and y < self.height:
            return self.tiles[y][x]
        else:
            return self.wall

    def copy(self):
        field = TetrisField(self.width, self.height)
        for y, tiles in enumerate(self):
            for x, tile in enumerate(tiles):
                field[x, y] = tile
        return field

    def can_put(self, tetrimino, x, y):
        tetrimino = tetrimino or self.wall
        return all(self[x + dx, y + dy] is None for dx, dy in tetrimino)

    def put(self, tetrimino, x, y):
        for dx, dy in tetrimino:
            self[x + dx, y + dy] = tetrimino

    def clear_complete_lines(self):
        for y, tiles in enumerate(self.tiles[:]):
            if all(tile is not None for tile in tiles):
                del self.tiles[y]
        clear_lines = self.height - len(self.tiles)
        if clear_lines > 0:
            self.tiles[:0] = [[None] * self.width for y in range(clear_lines)]
        return clear_lines
```

```py:model.py
import random
from tetrimino import tetriminos, tetrimino_names
from field import TetrisField


class TetrisModel:
    """
    # python -m doctest -v model.py
    >>> model = TetrisModel(4, 6)
    >>> model.put(tetrimino_names['T'], 2, 0)
    True
    >>> model.drop_down()
    >>> print(model)
    |    |
    |    |
    |    |
    |    |
    | ###|
    |  # |
    >>> model.put(tetrimino_names['I'], 0, 0)
    True
    >>> model.drop_down()
    >>> print(model)
    |    |
    |    |
    |    |
    |#   |
    |#   |
    |# # |
    >>> model.score
    1
    """

    SCORE = (0, 1, 2, 4, 8)  # by clear lines

    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.field = TetrisField(width, height)
        self.tetrimino = None
        self.x = 0
        self.y = 0
        self.score = 0
        self.alive = True

    def __iter__(self):
        field = self.field.copy()
        self.tetrimino and field.put(self.tetrimino, self.x, self.y)
        return iter(field)

    def __str__(self):
        field = self.field.copy()
        self.tetrimino and field.put(self.tetrimino, self.x, self.y)
        return str(field)

    def is_alive(self):
        return self.alive

    def dead(self):
        self.alive = False

    def put_new_tetrimino(self):
        tetrimino = random.choice(tetriminos)
        x = self.field.width // 2
        for y in range(tetrimino.min_y, tetrimino.max_y + 1):
            if self.field.can_put(tetrimino, x, -y):
                self.put(tetrimino, x, -y)
                return
        self.dead()

    def put(self, tetrimino, x, y):
        if not self.field.can_put(tetrimino, x, y):
            return False
        self.tetrimino = tetrimino
        self.x = x
        self.y = y
        return True

    def replace(self, tetrimino):
        return self.put(tetrimino, self.x, self.y)

    def move(self, x, y):
        return self.tetrimino and self.put(self.tetrimino, x, y)
        
    def move_left(self):
        self.move(self.x - 1, self.y)

    def move_right(self):
        self.move(self.x + 1, self.y)

    def rotate_clockwise(self):
        self.tetrimino and self.replace(self.tetrimino.rotate_clockwise())

    def rotate_unclockwise(self):
        self.tetrimino and self.replace(self.tetrimino.rotate_unclockwise())

    def move_down(self):
        if self.tetrimino is None:
            return False
        if self.move(self.x, self.y + 1):
            return True
        self.fixed()
        return False

    def drop_down(self):
        while self.move_down(): pass

    def fixed(self):
        if self.tetrimino is None:
            return True
        self.field.put(self.tetrimino, self.x, self.y)
        self.tetrimino = None
        clear_lines = self.field.clear_complete_lines()
        self.score += self.SCORE[clear_lines]
        return False
        
    def step(self):
        if not self.is_alive():
            return
        if self.tetrimino:
            self.move_down()
        else:
            self.put_new_tetrimino()
```

```py:qt5view.py
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMainWindow, QFrame, QDesktopWidget
from PyQt5.QtGui import QPainter, QColor

TILE_COLORS = {
    'Z': QColor(0xCC6666),
    'S': QColor(0x66CC66),
    'I': QColor(0x6666CC),
    'T': QColor(0xCCCC66),
    'O': QColor(0xCC66CC),
    'L': QColor(0x66CCCC),
    'J': QColor(0xDAAA00),
}


class ModelView:

    def __init__(self, parent, model):
        self.model = model
        self.widget = QFrame(parent)
        self.widget.setFocusPolicy(Qt.StrongFocus)
        self.widget.paintEvent = self.draw

    def draw(self, event):
        width  = self.widget.contentsRect().width()  // self.model.width
        height = self.widget.contentsRect().height() // self.model.height
        tiles = ((x, y, tile)
                 for y, tiles in enumerate(self.model)
                 for x, tile in enumerate(tiles)
                 if tile is not None)
        for x, y, tile in tiles:
            color = TILE_COLORS[tile.name]
            self.drawTile(x * width, y * width, width, height, color)

    def drawTile(self, x, y, width, height, color):
        x2, y2 = x + width, y + height

        painter = QPainter(self.widget)
        painter.fillRect(x, y, width, height, color)

        painter.setPen(color.lighter())
        painter.drawLine(x, y, x2, y)
        painter.drawLine(x, y, x, y2)

        painter.setPen(color.darker())
        painter.drawLine(x + 1, y2 - 1, x2 - 1, y2 - 1)
        painter.drawLine(x2 - 1, y + 1, x2 - 1, y2 - 1)


class TetrisView:

    def __init__(self, model, width, height):
        self.widget = QMainWindow()
        self.model_view = ModelView(self.widget, model)
        self.status = self.widget.statusBar()
        self.widget.setWindowTitle('Tetris')
        self.widget.setCentralWidget(self.model_view.widget)
        self.widget.resize(width, height)
        self.centering()

    def centering(self):
        screen = QDesktopWidget().screenGeometry()
        size = self.widget.geometry()
        self.widget.move((screen.width() - size.width()) // 2,
                         (screen.height() - size.height()) // 2)

    def show(self):
        self.widget.show()

    def showStatus(self, message):
        self.status.showMessage(message)

    def update(self):
        self.model_view.widget.update()
        self.widget.update()

    def on_keyin(self, handler):
        # call handler(key) when key is pressed
        def hook(event):
            handler(event.key())
        self.widget.keyPressEvent = hook

    def on_close(self, handler):
        def closeEvent(event):
            handler()
        self.widget.closeEvent = closeEvent
```

```py:qt5tetris.py
import sys
from PyQt5.QtCore import Qt, QObject, pyqtSignal, QBasicTimer
from PyQt5.QtWidgets import QApplication
from model import TetrisModel
from qt5view import TetrisView


class Tetris:
    SPEED = 300

    def __init__(self, app):
        self.app = app
        self.model = TetrisModel(width=10, height=20)
        self.view = TetrisView(self.model, width=180, height=380)
        self.status = Status()
        self.status.on(self.view.showStatus)
        self.key_map = self.make_key_map()
        self.view.on_keyin(self.keyin)
        self.view.on_close(self.quit)
        self.timer = Timer(self.step)
        self.is_started = False

    def make_key_map(self):
        return {
            Qt.Key_Q:     self.quit,
            Qt.Key_P:     self.pause,
            Qt.Key_Left:  self.model.move_left,
            Qt.Key_Right: self.model.move_right,
            Qt.Key_Up:    self.model.rotate_clockwise,
            Qt.Key_Down:  self.model.rotate_unclockwise,
            Qt.Key_Space: self.model.drop_down,
            Qt.Key_D:     self.model.move_down,
        }

    def keyin(self, key):
        if key not in self.key_map:
            return False
        self.key_map[key]()
        return True

    def start(self):
        if not self.is_started:
            self.is_started = True
            self.view.show()
            self.timer.start(self.SPEED)

    def step(self):
        self.model.step()
        if self.model.is_alive():
            self.status.set(f"Score: {self.model.score}")
        else:
            self.status.set(f"Game Over (Score: {self.model.score})")
        self.view.update()

    def pause(self):
        if self.is_started:
            return
        self.is_paused = not self.is_paused
        if self.is_paused:
            self.timer.stop()
            self.status.set("Paused")
        else:
            self.timer.start(self.SPEED)
            self.status.set(f"Score: {self.model.score}")

    def quit(self):
        self.app.quit()


class Status(QObject):
    signal = pyqtSignal(str)

    def on(self, handler):
        self.signal[str].connect(handler)

    def set(self, message):
        # It will call handler(message) that set self.on(handler)
        self.signal.emit(message)


class Timer(QObject):

    def __init__(self, handler):
        super().__init__()
        self.handler = handler
        self.timer = QBasicTimer()

    def start(self, interval):
        # It will call self.timerEvent(event) after each interval
        self.timer.start(interval, self)

    def stop(self):
        self.timer.stop()

    def timerEvent(self, event):
        if event.timerId() == self.timer.timerId():
            self.handler()


def main():
    app = QApplication(sys.argv)
    tetris = Tetris(app).start()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()
```