Repository: pibooth/pibooth (language: python)

Issue:
{date} in footer text throws execption
I used the example from the docs 

```
# Secondary text displayed
footer_text2 = A custom date is {date.year}-{date.month}-{date.day}
```
I tried it without quotes and with quotes.

It always gives me the exception when I do pibooth-regen:

```
Traceback (most recent call last):
  File "/usr/local/bin/pibooth-regen", line 8, in <module>
    sys.exit(main())
  File "/usr/local/lib/python3.9/dist-packages/pibooth/scripts/regenerate.py", line 81, in main
    regenerate_all_images(plugin_manager, config, path)
  File "/usr/local/lib/python3.9/dist-packages/pibooth/scripts/regenerate.py", line 55, in regenerate_all_images
    factory = plugin_manager.hook.pibooth_setup_picture_factory(cfg=config,
  File "/usr/local/lib/python3.9/dist-packages/pluggy/_hooks.py", line 265, in __call__
    return self._hookexec(self.name, self.get_hookimpls(), kwargs, firstresult)
  File "/usr/local/lib/python3.9/dist-packages/pluggy/_manager.py", line 80, in _hookexec
    return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
  File "/usr/local/lib/python3.9/dist-packages/pluggy/_manager.py", line 327, in traced_hookexec
    return outcome.get_result()
  File "/usr/local/lib/python3.9/dist-packages/pluggy/_result.py", line 60, in get_result
    raise ex[1].with_traceback(ex[2])
  File "/usr/local/lib/python3.9/dist-packages/pluggy/_result.py", line 33, in from_call
    result = func()
  File "/usr/local/lib/python3.9/dist-packages/pluggy/_manager.py", line 324, in <lambda>
    lambda: oldcall(hook_name, hook_impls, kwargs, firstresult)
  File "/usr/local/lib/python3.9/dist-packages/pluggy/_callers.py", line 55, in _multicall
    gen.send(outcome)
  File "/usr/local/lib/python3.9/dist-packages/pibooth/plugins/picture_plugin.py", line 49, in pibooth_setup_picture_factory
    cfg.get('PICTURE', 'footer_text2').strip('"').format(**self.texts_vars)]
KeyError: 'date'
```
What am I doing wrong?
Thx


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml
new file mode 100644
index 00000000..5ea8ee55
--- /dev/null
+++ b/.github/workflows/python-package.yml
@@ -0,0 +1,43 @@
+# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
+
+name: Python package
+
+on:
+  push:
+    branches: [ "master" ]
+  pull_request:
+    branches: [ "master" ]
+
+jobs:
+  build:
+
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: ["3.8", "3.9", "3.10"]
+
+    steps:
+    - uses: actions/checkout@v3
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v3
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install flake8 pytest pylint
+        if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
+    - name: Lint with flake8
+      run: |
+        # stop the build if there are Python syntax errors or undefined names
+        flake8 pibooth --count --select=E9,F63,F7,F82 --show-source --statistics
+        # exit-zero treats all errors as warnings.
+        flake8 pibooth --count --exit-zero --max-complexity=10 --max-line-length=160 --statistics
+    - name: Lint with pylint
+      run: |
+        pylint $(git ls-files '*.py') || echo "pylint exited with $?"
+    - name: Test with pytest
+      run: |
+        pytest
diff --git a/README.rst b/README.rst
index 2eebba39..684299db 100755
--- a/README.rst
+++ b/README.rst
@@ -26,7 +26,9 @@ Features
 * Custom texts can be added on the final picture (customizable fonts, colors, alignments)
 * Custom background(s) and overlay(s) can be added on the final picture
 * All settings available in a configuration file (most common options in a graphical interface)
-* Highly customizable thanks to its plugin system, you can develop your own plugin
+* Highly customizable thanks to its plugin system, you can install
+  `plugins developed by the community from PyPI  <https://pypi.org/search/?q=pibooth>`_
+  or develop your own plugin.
 
 Documentation
 -------------
@@ -37,6 +39,17 @@ Documentation
    :target: https://pibooth.readthedocs.io/en/latest
    :height: 200px
 
+Third-party plugins
+-------------------
+
+Third-party plugins can be found on GitHub or on `plugins on PyPI  <https://pypi.org/search/?q=pibooth>`_.
+Here is a short list:
+
+- `pibooth-lcd-display <https://pypi.org/project/pibooth-lcd-display>`_
+- `pibooth-neopixel_spi <https://github.com/peteoheat/pibooth-neopixel_spi>`_
+- `pibooth-telegram-upload <https://pypi.org/project/pibooth-telegram-upload>`_
+- `pibooth-s3-upload <https://pypi.org/project/pibooth-s3-upload>`_
+
 .. |Pibooth| image:: https://raw.githubusercontent.com/pibooth/pibooth/master/docs/pibooth.png
    :align: middle
 
diff --git a/docs/sources/install.rst b/docs/sources/install.rst
index 08452dec..aa07e797 100644
--- a/docs/sources/install.rst
+++ b/docs/sources/install.rst
@@ -27,7 +27,7 @@ Hardware
 Software
 ^^^^^^^^
 
-* Raspberry Pi OS with desktop
+* Raspberry Pi OS **Bullseye** with desktop
 * Python ``3.7.3``
 * libsdl2 ``2.0``
 * libgphoto2 ``2.5.27``
@@ -43,6 +43,9 @@ If you intend to develop on ``pibooth``, an editable/customizable version can be
 installed. Instead of doing step 8. of the below procedure, follow
 :ref:`instructions here<install_developing_version>`.
 
+Manual procedure
+^^^^^^^^^^^^^^^^
+
 1. Download the Raspbian image and set-up an SD-card. You can follow
    `these instructions <https://www.raspberrypi.org/documentation/installation/installing-images/README.md>`_.
 
@@ -70,8 +73,9 @@ installed. Instead of doing step 8. of the below procedure, follow
 
    .. code-block:: bash
 
-        sudo wget raw.github.com/gonzalo/gphoto2-updater/master/gphoto2-updater.sh
-        sudo chmod 755 gphoto2-updater.sh
+        wget https://raw.githubusercontent.com/gonzalo/gphoto2-updater/master/gphoto2-updater.sh
+        wget https://raw.githubusercontent.com/gonzalo/gphoto2-updater/master/.env
+        chmod +x gphoto2-updater.sh
         sudo ./gphoto2-updater.sh
 
 6. Optionally install ``CUPS`` to handle printers (more instructions to add a
@@ -81,8 +85,10 @@ installed. Instead of doing step 8. of the below procedure, follow
    .. code-block:: bash
 
         sudo apt-get install cups libcups2-dev
+        sudo apt-get install printer-driver-gutenprint
 
-7. Optionally install ``OpenCV`` (required only for Webcam):
+7. Optionally install ``OpenCV`` to improve images generation efficiency or if a
+   Webcam is used:
 
    .. code-block:: bash
 
@@ -97,6 +103,14 @@ installed. Instead of doing step 8. of the below procedure, follow
    .. hint:: If you don't have ``gPhoto2`` and/or ``CUPS`` installed (steps 5. and/
           or 6. skipped), remove **printer** and/or **dslr** under the ``[]``
 
+Automated procedure
+^^^^^^^^^^^^^^^^^^^
+
+Alternatively, you can use Ansible to install pibooth automatically.
+`A playbook can be found here <https://github.com/TiJof/pibooth_ansible>`_
+(thank you **TiJof**).
+
+
 Circuit diagram
 ---------------
 
diff --git a/docs/sources/plugins/pibooth_getready_text.py b/docs/sources/plugins/pibooth_getready_text.py
deleted file mode 100644
index 54cadb1c..00000000
--- a/docs/sources/plugins/pibooth_getready_text.py
+++ /dev/null
@@ -1,38 +0,0 @@
-"""Plugin to display 'Get Ready' at screen after 'wait' state."""
-
-import time
-import pygame
-import pibooth
-from pibooth import pictures, fonts
-
-__version__ = "0.0.2"
-
-
-@pibooth.hookimpl
-def state_wait_exit(win):
-    win_rect = win.get_rect()
-    text = "Get Ready!"
-
-    # Get best font size according to window size
-    font = fonts.get_pygame_font(text, fonts.CURRENT,
-                                 win_rect.width//1.5, win_rect.height//1.5)
-
-    # Build a surface to display at screen
-    text_surface = font.render(text, True, win.text_color)
-
-    # Clear screen
-    if isinstance(win.bg_color, (tuple, list)):
-        win.surface.fill(win.bg_color)
-    else:
-        bg_surface = pictures.get_pygame_image(win.bg_color, win_rect.size, crop=True, color=None)
-        win.surface.blit(bg_surface, (0, 0))
-
-    # Draw the surface at screen
-    win.surface.blit(text_surface, text_surface.get_rect(center=win_rect.center).topleft)
-
-    # Force screen update and events process
-    pygame.display.update()
-    pygame.event.pump()
-
-    # Wait 1s
-    time.sleep(1)
diff --git a/docs/sources/plugins/plugins.rst b/docs/sources/plugins/plugins.rst
index 1ab2238f..e7ef713d 100644
--- a/docs/sources/plugins/plugins.rst
+++ b/docs/sources/plugins/plugins.rst
@@ -10,7 +10,7 @@ that gives users the ability to extend or modify its behavior thanks to plugins.
 Several plugins maintained by the community are available. They add extra features to
 ``pibooth``. Have a look to the `plugins on PyPI  <https://pypi.org/search/?q=pibooth>`_.
 
-You can also easily develop your own plugin, and declare it in the ``[GENERAL][plugins]``
+You can also easily develop your own plugin, and **declare it** in the ``[GENERAL][plugins]``
 key of the configuration.
 
 What is a plugin?
@@ -31,7 +31,8 @@ Influencing states
 ^^^^^^^^^^^^^^^^^^
 
 The ``pibooth`` application is built on the principle of states. Each state
-is defined by a specific screen and possible actions available to the user.
+is defined by a specific ``scene`` (with same name as state one) and possible actions available
+to the user.
 
 The following states are defined:
 
@@ -107,18 +108,18 @@ Access to internal variables
 cfg
 ~~~
 
-.. autoclass:: pibooth.config.parser.PiConfigParser
+.. autoclass:: pibooth.config.parser.PiboothConfigParser
    :members: set, get, getint, getfloat, getboolean, gettyped, getpath, gettuple, add_option, join_path
    :undoc-members:
 
 app
 ~~~
 
-.. autoclass:: pibooth.booth.PiApplication
+.. autoclass:: pibooth.booth.PiboothApplication
    :members:
 
 win
 ~~~
 
-.. autoclass:: pibooth.view.window.PiWindow
+.. autoclass:: pibooth.view.base.BaseWindow
    :members:
diff --git a/docs/sources/scripts.rst b/docs/sources/scripts.rst
index 3d4b24b8..6a517683 100644
--- a/docs/sources/scripts.rst
+++ b/docs/sources/scripts.rst
@@ -68,7 +68,7 @@ The output can be formatted in **json** using the ``--json`` option:
 
     {"taken": 126, "printed": 17, "forgotten": 2, "remaining_duplicates": 3}
 
-The counters can be updated/rest using the ``--update`` option:
+The counters can be interactively updated/rest using the ``--update`` option:
 
 .. code-block:: bash
 
@@ -93,7 +93,7 @@ in the configuration:
 
 .. code-block:: bash
 
-    pibooth-printcfg
+    pibooth-printer
 
 *Output example*::
 
@@ -119,7 +119,7 @@ generated output can be pasted (after update of the wanted values) in the
 
 .. code-block:: bash
 
-    pibooth-printcfg --json
+    pibooth-printer --json
 
 *Output example*::
 
diff --git a/docs/sources/start.rst b/docs/sources/start.rst
index 60982b8f..88a9c82d 100644
--- a/docs/sources/start.rst
+++ b/docs/sources/start.rst
@@ -47,12 +47,11 @@ After the graphical interface is started, the following actions are available:
 Action                  Keyboard key     Physical button       Touch event
 ======================= ================ ===================== =====================
 Toggle Full screen      Ctrl + F         \-                    \-
-Choose layout           LEFT or RIGHT    Button 1 or Button 2  Tap 1 finger
-Take pictures           P                Button 1              Tap 1 finger
-Export Printer/Cloud    Ctrl + E         Button 2              Tap 1 finger
+Capture / Validate      C                Button 1              Tap 1 finger
+Print / Select          P                Button 2              Tap 1 finger
 Open/close settings     ESC              Button 1 + Button 2   Tap 4 finger
-Select option           UP or DOWN       Button 1              Tap 1 finger
-Change option value     LEFT or RIGHT    Button 2              Tap 1 finger
+Select option           UP or DOWN       Button 2              Tap 1 finger
+Change option value     LEFT or RIGHT    Button 1              Tap 1 finger
 ======================= ================ ===================== =====================
 
 Configure
diff --git a/pibooth/__main__.py b/pibooth/__main__.py
new file mode 100644
index 00000000..09d7fc98
--- /dev/null
+++ b/pibooth/__main__.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""Pibooth entry point.
+"""
+
+import tempfile
+import logging
+import argparse
+import os.path as osp
+import multiprocessing
+from warnings import filterwarnings
+
+from gpiozero import Device, pi_info
+from gpiozero.pins.mock import MockFactory
+from gpiozero.exc import BadPinFactory, PinFactoryFallback
+
+import pibooth
+from pibooth.app import PiboothApplication
+from pibooth import language
+from pibooth.utils import LOGGER, configure_logging
+from pibooth.plugins import create_plugin_manager
+from pibooth.config import PiboothConfigParser
+
+
+# Set the default pin factory to a mock factory if pibooth is not started a Raspberry Pi
+try:
+    filterwarnings("ignore", category=PinFactoryFallback)
+    GPIO_INFO = "on Raspberry pi {0}".format(pi_info().model)
+except BadPinFactory:
+    Device.pin_factory = MockFactory()
+    GPIO_INFO = "without physical GPIO, fallback to GPIO mock"
+
+
+def main():
+    """Application entry point.
+    """
+    if hasattr(multiprocessing, 'set_start_method'):
+        # Avoid use 'fork': safely forking a multithreaded process is problematic
+        multiprocessing.set_start_method('spawn')
+
+    parser = argparse.ArgumentParser(usage="%(prog)s [options]", description=pibooth.__doc__)
+
+    parser.add_argument("config_directory", nargs='?', default="~/.config/pibooth",
+                        help=u"path to configuration directory (default: %(default)s)")
+
+    parser.add_argument('--version', action='version', version=pibooth.__version__,
+                        help=u"show program's version number and exit")
+
+    parser.add_argument("--config", action='store_true',
+                        help=u"edit the current configuration and exit")
+
+    parser.add_argument("--translate", action='store_true',
+                        help=u"edit the GUI translations and exit")
+
+    parser.add_argument("--reset", action='store_true',
+                        help=u"restore the default configuration/translations and exit")
+
+    parser.add_argument("--nolog", action='store_true', default=False,
+                        help=u"don't save console output in a file (avoid filling the /tmp directory)")
+
+    parser.add_argument("--nogui", dest='gui', action='store_const', const='nogui',
+                        help=u"don't show Graphical User Interface", default='pygame')
+
+    parser.add_argument("--noplugin", action='store_true', default=False,
+                        help=u"don't load external plugins")
+
+    parser.add_argument("--profile", action='store_true',
+                        help=u"run the profiler to do CPU times analysis")
+
+    group = parser.add_mutually_exclusive_group()
+    group.add_argument("-v", "--verbose", dest='logging', action='store_const', const=logging.DEBUG,
+                       help=u"report more information about operations", default=logging.INFO)
+    group.add_argument("-q", "--quiet", dest='logging', action='store_const', const=logging.WARNING,
+                       help=u"report only errors and warnings", default=logging.INFO)
+
+    options = parser.parse_args()
+
+    if not options.nolog:
+        filename = osp.join(tempfile.gettempdir(), 'pibooth.log')
+    else:
+        filename = None
+    configure_logging(options.logging, '[ %(levelname)-8s] %(name)-18s: %(message)s', filename=filename)
+
+    plugin_manager = create_plugin_manager()
+
+    # Load the configuration
+    config = PiboothConfigParser(osp.join(options.config_directory, "pibooth.cfg"), plugin_manager, not options.reset)
+
+    # Register plugins
+    plugin_manager.load_all_plugins(config.gettuple('GENERAL', 'plugins', 'path'),
+                                    'all' if options.noplugin else config.gettuple('GENERAL', 'plugins_disabled', str))
+    LOGGER.info("Installed plugins: %s", ", ".join(
+        [plugin_manager.get_friendly_name(p) for p in plugin_manager.list_external_plugins()]))
+
+    # Load the languages
+    language.init(config.join_path("translations.cfg"), options.reset)
+
+    # Update configuration with plugins ones
+    plugin_manager.hook.pibooth_configure(cfg=config)
+
+    # Ensure config files are present in case of first pibooth launch
+    if not options.reset:
+        if not osp.isfile(config.filename):
+            config.save(default=True)
+        plugin_manager.hook.pibooth_reset(cfg=config, hard=False)
+
+    if options.config:
+        LOGGER.info("Editing the pibooth configuration...")
+        config.edit()
+    elif options.translate:
+        LOGGER.info("Editing the GUI translations...")
+        language.edit()
+    elif options.reset:
+        config.save(default=True)
+        plugin_manager.hook.pibooth_reset(cfg=config, hard=True)
+    else:
+        LOGGER.info("Starting the photo booth application %s", GPIO_INFO)
+        app = PiboothApplication(config, plugin_manager, options.gui)
+        app.exec(options.profile)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/pibooth/app.py b/pibooth/app.py
new file mode 100644
index 00000000..2079ff73
--- /dev/null
+++ b/pibooth/app.py
@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""Pibooth main module.
+"""
+
+import os
+import os.path as osp
+import logging
+import pstats
+import cProfile
+
+import pygame
+from PIL import Image
+from gpiozero import ButtonBoard, LEDBoard
+
+import pibooth
+from pibooth import view, fonts, language, evts
+from pibooth.counters import Counters
+from pibooth.states import StateMachine
+from pibooth.printer import Printer
+from pibooth.tasks import AsyncTasksPool
+from pibooth.utils import LOGGER, PollingTimer, get_crash_message, set_logging_level
+
+
+def load_last_saved_picture(path):
+    """Load the saved picture last time pibooth was started.
+
+    :return: PIL.Image instance and path
+    :rtype: tuple
+    """
+    for name in sorted(os.listdir(path), reverse=True):
+        filename = osp.join(path, name)
+        if osp.isfile(filename) and osp.splitext(name)[-1] == '.jpg':
+            return (Image.open(filename), filename)
+    return (None, None)
+
+
+class PiboothApplication(object):
+
+    """Main class representing the ``pibooth`` software.
+    The following attributes are available for use in plugins:
+
+    : attr capture_nbr: number of capture to be done in the current sequence
+    : type capture_nbr: int
+    : attr capture_date: date (% Y-%m-%d-%H-%M-%S) of the first capture of the current sequence
+    : type capture_date: str
+    : attr capture_choices: possible choices of captures numbers.
+    : type capture_choices: tuple
+    : attr previous_picture: picture generated during last sequence
+    : type previous_picture: : py: class: `PIL.Image`
+    : attr previous_animated: infinite list of picture to display during animation
+    : type previous_animated: : py: func: `itertools.cycle`
+    : attr previous_picture_file: file name of the picture generated during last sequence
+    : type previous_picture_file: str
+    : attr count: holder for counter values
+    : type count: : py: class: `pibooth.counters.Counters`
+    : attr camera: camera used
+    : type camera: : py: class: `pibooth.camera.base.BaseCamera`
+    : attr buttons: access to hardware buttons ``capture`` and ``printer``
+    : type buttons: : py: class: `gpiozero.ButtonBoard`
+    : attr leds: access to hardware LED ``capture`` and ``printer``
+    : attr leds: : py: class: `gpiozero.LEDBoard`
+    : attr printer: printer used
+    : type printer: : py: class: `pibooth.printer.Printer`
+    """
+
+    def __init__(self, config, plugin_manager, window_type='pygame'):
+        self._pm = plugin_manager
+        self._config = config
+        self._tasks = AsyncTasksPool()
+
+        # Create directories where pictures are saved
+        for savedir in config.gettuple('GENERAL', 'directory', 'path'):
+            if not osp.isdir(savedir):
+                os.makedirs(savedir)
+
+        # Create window of (width, height)
+        init_size = self._config.gettyped('WINDOW', 'size')
+        init_debug = self._config.getboolean('GENERAL', 'debug')
+        init_color = self._config.gettyped('WINDOW', 'background')
+        init_text_color = self._config.gettyped('WINDOW', 'text_color')
+        if not isinstance(init_color, (tuple, list)):
+            init_color = self._config.getpath('WINDOW', 'background')
+
+        title = 'Pibooth v{}'.format(pibooth.__version__)
+        self._window = view.get_window(window_type, title, init_size, init_color, init_text_color, init_debug)
+        self._window.set_menu(self, self._config)
+
+        self._multipress_timer = PollingTimer(config.getfloat('CONTROLS', 'multi_press_delay'), False)
+
+        # Define states of the application
+        self._machine = StateMachine(self._pm, self._config, self, self._window)
+        self._machine.add_state('wait')
+        self._machine.add_state('choose')
+        self._machine.add_state('chosen')
+        self._machine.add_state('preview')
+        self._machine.add_state('capture')
+        self._machine.add_state('processing')
+        self._machine.add_state('print')
+        self._machine.add_state('finish')
+
+        # ---------------------------------------------------------------------
+        # Variables shared with plugins
+        # Change them may break plugins compatibility
+        self.capture_nbr = None
+        self.capture_date = None
+        self.capture_choices = (4, 1)
+
+        self.previous_animated = None
+        self.previous_picture, self.previous_picture_file = load_last_saved_picture(
+            config.gettuple('GENERAL', 'directory', 'path')[0])
+
+        self.count = Counters(self._config.join_path("counters.pickle"),
+                              taken=0, printed=0, forgotten=0,
+                              remaining_duplicates=self._config.getint('PRINTER', 'max_duplicates'))
+
+        self.camera = self._pm.hook.pibooth_setup_camera(cfg=self._config)
+
+        self.buttons = ButtonBoard(capture="BOARD" + config.get('CONTROLS', 'picture_btn_pin'),
+                                   printer="BOARD" + config.get('CONTROLS', 'print_btn_pin'),
+                                   hold_time=config.getfloat('CONTROLS', 'debounce_delay'),
+                                   pull_up=True)
+        self.buttons.capture.when_held = self._on_button_capture_held
+        self.buttons.printer.when_held = self._on_button_printer_held
+
+        self.leds = LEDBoard(capture="BOARD" + config.get('CONTROLS', 'picture_led_pin'),
+                             printer="BOARD" + config.get('CONTROLS', 'print_led_pin'))
+
+        self.printer = Printer(config.get('PRINTER', 'printer_name'),
+                               config.getint('PRINTER', 'max_pages'),
+                               config.gettyped('PRINTER', 'printer_options'),
+                               self.count)
+        # ---------------------------------------------------------------------
+
+    def _initialize(self):
+        """Restore the application with initial parameters defined in the
+        configuration file.
+        Only parameters that can be changed at runtime are restored.
+        """
+        # Handle the language configuration
+        language.CURRENT = self._config.get('GENERAL', 'language')
+        if self._config.get('WINDOW', 'font').endswith('.ttf') or self._config.get('WINDOW', 'font').endswith('.otf'):
+            fonts.CURRENT = fonts.get_filename(self._config.getpath('WINDOW', 'font'))
+        else:
+            fonts.CURRENT = fonts.get_filename(self._config.get('WINDOW', 'font'))
+
+        # Set the captures choices
+        choices = self._config.gettuple('PICTURE', 'captures', int)
+        for chx in choices:
+            if chx not in [1, 2, 3, 4]:
+                LOGGER.warning("Invalid captures number '%s' in config, fallback to '%s'",
+                               chx, self.capture_choices)
+                choices = self.capture_choices
+                break
+        self.capture_choices = choices
+
+        # Handle autostart of the application
+        self._config.handle_autostart()
+
+        self._window.arrow_location = self._config.get('WINDOW', 'arrows')
+        self._window.arrow_offset = self._config.getint('WINDOW', 'arrows_x_offset')
+        self._window.text_color = self._config.gettyped('WINDOW', 'text_color')
+
+        # Handle window size
+        size = self._config.gettyped('WINDOW', 'size')
+        if isinstance(size, str) and size.lower() == 'fullscreen':
+            if not self._window.is_fullscreen:
+                self._window.toggle_fullscreen()
+        else:
+            if self._window.is_fullscreen:
+                self._window.toggle_fullscreen()
+        self._window.debug = self._config.getboolean('GENERAL', 'debug')
+
+        # Handle debug mode
+        if not self._config.getboolean('GENERAL', 'debug'):
+            set_logging_level()  # Restore default level
+            self._machine.add_failsafe_state('failsafe')
+        else:
+            set_logging_level(logging.DEBUG)
+            self._machine.remove_state('failsafe')
+
+        # Reset the print counter (in case of max_pages is reached)
+        self.printer.max_pages = self._config.getint('PRINTER', 'max_pages')
+
+    def _on_button_capture_held(self):
+        """Called when the capture button is pressed.
+        """
+        if all(self.buttons.value):
+            self.buttons.capture.hold_repeat = True
+            if self._multipress_timer.elapsed() == 0:
+                self._multipress_timer.start()
+            if self._multipress_timer.is_timeout():
+                # Capture was held while printer was pressed
+                self.buttons.capture.hold_repeat = False
+                self._multipress_timer.reset()
+                evts.post_button_settings_event()
+        else:
+            # Capture was held but printer not pressed
+            self.buttons.capture.hold_repeat = False
+            self._multipress_timer.reset()
+            evts.post_button_capture_event()
+
+    def _on_button_printer_held(self):
+        """Called when the printer button is pressed.
+        """
+        if all(self.buttons.value):
+            # Printer was held while capture was pressed
+            # but don't do anything here, let capture_held handle it instead
+            pass
+        else:
+            # Printer was held but capture not pressed
+            evts.post_button_print_event()
+
+    @property
+    def picture_filename(self):
+        """Return the final picture file name.
+        """
+        if not self.capture_date:
+            raise EnvironmentError("The 'capture_date' attribute is not set yet")
+        return "{}_pibooth.jpg".format(self.capture_date)
+
+    def update(self, events):
+        """Update application and call plugins according to Pygame events.
+        Better to call it in the main thread to avoid plugin thread-safe issues.
+
+        :param events: list of events to process.
+        :type events: list
+        """
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_SETTINGS):
+            if not self._window.is_menu_shown:  # Settings menu is opened
+                self.camera.stop_preview()
+                self.leds.off()
+                self.leds.blink(on_time=0.1, off_time=1)
+            elif self._window.is_menu_shown:  # Settings menu is closed
+                self.leds.off()
+                self._initialize()
+                self._machine.set_state('wait')
+        else:
+            self._machine.process(events)
+
+    def exec(self, enable_profiler=False):
+        """Start application.
+        """
+        if enable_profiler:
+            profiler = cProfile.Profile()
+
+        try:
+            self._initialize()
+            self._pm.hook.pibooth_startup(cfg=self._config, app=self)
+            self._machine.set_state('wait')
+            if enable_profiler:
+                profiler.enable()
+            self._window.eventloop(self.update)
+        except KeyboardInterrupt:
+            print()
+        except Exception as ex:
+            LOGGER.error(str(ex), exc_info=True)
+            LOGGER.error(get_crash_message())
+        finally:
+
+            if enable_profiler:
+                stats = pstats.Stats(profiler).sort_stats('cumtime')
+                stats.print_stats()
+                profiler.disable()
+
+            self._pm.hook.pibooth_cleanup(app=self)
+            self._tasks.quit()
+            pygame.quit()
diff --git a/pibooth/booth.py b/pibooth/booth.py
deleted file mode 100644
index 21c16906..00000000
--- a/pibooth/booth.py
+++ /dev/null
@@ -1,496 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-
-"""Pibooth main module.
-"""
-
-import os
-import os.path as osp
-import tempfile
-import shutil
-import logging
-import argparse
-import multiprocessing
-from warnings import filterwarnings
-
-import pygame
-from gpiozero import Device, ButtonBoard, LEDBoard, pi_info
-from gpiozero.exc import BadPinFactory, PinFactoryFallback
-
-import pibooth
-from pibooth import fonts
-from pibooth import language
-from pibooth.counters import Counters
-from pibooth.utils import (LOGGER, PollingTimer, configure_logging, get_crash_message,
-                           set_logging_level, get_event_pos, AsyncTask)
-from pibooth.states import StateMachine
-from pibooth.plugins import create_plugin_manager
-from pibooth.view import PiWindow, PiConfigMenu
-from pibooth.config import PiConfigParser
-from pibooth.printer import EVT_PRINTER_TASKS_UPDATED, Printer
-
-
-# Set the default pin factory to a mock factory if pibooth is not started a Raspberry Pi
-try:
-    filterwarnings("ignore", category=PinFactoryFallback)
-    GPIO_INFO = "on Raspberry pi {0}".format(pi_info().model)
-except BadPinFactory:
-    from gpiozero.pins.mock import MockFactory
-    Device.pin_factory = MockFactory()
-    GPIO_INFO = "without physical GPIO, fallback to GPIO mock"
-
-
-EVT_BUTTONDOWN = pygame.USEREVENT + 1
-
-
-class PiApplication(object):
-
-    """Main class representing the ``pibooth`` software.
-    The following attributes are available for use in plugins:
-
-    :attr capture_nbr: number of capture to be done in the current sequence
-    :type capture_nbr: int
-    :attr capture_date: date (%Y-%m-%d-%H-%M-%S) of the first capture of the current sequence
-    :type capture_date: str
-    :attr capture_choices: possible choices of captures numbers.
-    :type capture_choices: tuple
-    :attr previous_picture: picture generated during last sequence
-    :type previous_picture: :py:class:`PIL.Image`
-    :attr previous_animated: infinite list of picture to display during animation
-    :type previous_animated: :py:func:`itertools.cycle`
-    :attr previous_picture_file: file name of the picture generated during last sequence
-    :type previous_picture_file: str
-    :attr count: holder for counter values
-    :type count: :py:class:`pibooth.counters.Counters`
-    :attr camera: camera used
-    :type camera: :py:class:`pibooth.camera.base.BaseCamera`
-    :attr buttons: access to hardware buttons ``capture`` and ``printer``
-    :type buttons: :py:class:`gpiozero.ButtonBoard`
-    :attr leds: access to hardware LED ``capture`` and ``printer``
-    :attr leds: :py:class:`gpiozero.LEDBoard`
-    :attr printer: printer used
-    :type printer: :py:class:`pibooth.printer.Printer`
-    """
-
-    def __init__(self, config, plugin_manager):
-        self._pm = plugin_manager
-        self._config = config
-
-        # Create directories where pictures are saved
-        for savedir in config.gettuple('GENERAL', 'directory', 'path'):
-            if osp.isdir(savedir) and config.getboolean('GENERAL', 'debug'):
-                shutil.rmtree(savedir)
-            if not osp.isdir(savedir):
-                os.makedirs(savedir)
-
-        # Create window of (width, height)
-        init_size = self._config.gettyped('WINDOW', 'size')
-        init_debug = self._config.getboolean('GENERAL', 'debug')
-        init_color = self._config.gettyped('WINDOW', 'background')
-        init_text_color = self._config.gettyped('WINDOW', 'text_color')
-        if not isinstance(init_color, (tuple, list)):
-            init_color = self._config.getpath('WINDOW', 'background')
-
-        title = 'Pibooth v{}'.format(pibooth.__version__)
-        if not isinstance(init_size, str):
-            self._window = PiWindow(title, init_size, color=init_color,
-                                    text_color=init_text_color, debug=init_debug)
-        else:
-            self._window = PiWindow(title, color=init_color,
-                                    text_color=init_text_color, debug=init_debug)
-
-        self._menu = None
-        self._multipress_timer = PollingTimer(config.getfloat('CONTROLS', 'multi_press_delay'), False)
-        self._fingerdown_events = []
-
-        # Define states of the application
-        self._machine = StateMachine(self._pm, self._config, self, self._window)
-        self._machine.add_state('wait')
-        self._machine.add_state('choose')
-        self._machine.add_state('chosen')
-        self._machine.add_state('preview')
-        self._machine.add_state('capture')
-        self._machine.add_state('processing')
-        self._machine.add_state('print')
-        self._machine.add_state('finish')
-
-        # ---------------------------------------------------------------------
-        # Variables shared with plugins
-        # Change them may break plugins compatibility
-        self.capture_nbr = None
-        self.capture_date = None
-        self.capture_choices = (4, 1)
-        self.previous_picture = None
-        self.previous_animated = None
-        self.previous_picture_file = None
-
-        self.count = Counters(self._config.join_path("counters.pickle"),
-                              taken=0, printed=0, forgotten=0,
-                              remaining_duplicates=self._config.getint('PRINTER', 'max_duplicates'))
-
-        self.camera = self._pm.hook.pibooth_setup_camera(cfg=self._config)
-
-        self.buttons = ButtonBoard(capture="BOARD" + config.get('CONTROLS', 'picture_btn_pin'),
-                                   printer="BOARD" + config.get('CONTROLS', 'print_btn_pin'),
-                                   hold_time=config.getfloat('CONTROLS', 'debounce_delay'),
-                                   pull_up=True)
-        self.buttons.capture.when_held = self._on_button_capture_held
-        self.buttons.printer.when_held = self._on_button_printer_held
-
-        self.leds = LEDBoard(capture="BOARD" + config.get('CONTROLS', 'picture_led_pin'),
-                             printer="BOARD" + config.get('CONTROLS', 'print_led_pin'))
-
-        self.printer = Printer(config.get('PRINTER', 'printer_name'),
-                               config.getint('PRINTER', 'max_pages'),
-                               config.gettyped('PRINTER', 'printer_options'),
-                               self.count)
-        # ---------------------------------------------------------------------
-
-    def _initialize(self):
-        """Restore the application with initial parameters defined in the
-        configuration file.
-        Only parameters that can be changed at runtime are restored.
-        """
-        # Handle the language configuration
-        language.CURRENT = self._config.get('GENERAL', 'language')
-        fonts.CURRENT = fonts.get_filename(self._config.get('WINDOW', 'font'))
-
-        # Set the captures choices
-        choices = self._config.gettuple('PICTURE', 'captures', int)
-        for chx in choices:
-            if chx not in [1, 2, 3, 4]:
-                LOGGER.warning("Invalid captures number '%s' in config, fallback to '%s'",
-                               chx, self.capture_choices)
-                choices = self.capture_choices
-                break
-        self.capture_choices = choices
-
-        # Handle autostart of the application
-        self._config.handle_autostart()
-
-        self._window.arrow_location = self._config.get('WINDOW', 'arrows')
-        self._window.arrow_offset = self._config.getint('WINDOW', 'arrows_x_offset')
-        self._window.text_color = self._config.gettyped('WINDOW', 'text_color')
-        self._window.drop_cache()
-
-        # Handle window size
-        size = self._config.gettyped('WINDOW', 'size')
-        if isinstance(size, str) and size.lower() == 'fullscreen':
-            if not self._window.is_fullscreen:
-                self._window.toggle_fullscreen()
-        else:
-            if self._window.is_fullscreen:
-                self._window.toggle_fullscreen()
-        self._window.debug = self._config.getboolean('GENERAL', 'debug')
-
-        # Handle debug mode
-        if not self._config.getboolean('GENERAL', 'debug'):
-            set_logging_level()  # Restore default level
-            self._machine.add_failsafe_state('failsafe')
-        else:
-            set_logging_level(logging.DEBUG)
-            self._machine.remove_state('failsafe')
-
-        # Reset the print counter (in case of max_pages is reached)
-        self.printer.max_pages = self._config.getint('PRINTER', 'max_pages')
-
-    def _on_button_capture_held(self):
-        """Called when the capture button is pressed.
-        """
-        if all(self.buttons.value):
-            self.buttons.capture.hold_repeat = True
-            if self._multipress_timer.elapsed() == 0:
-                self._multipress_timer.start()
-            if self._multipress_timer.is_timeout():
-                # Capture was held while printer was pressed
-                if self._menu and self._menu.is_shown():
-                    # Convert HW button events to keyboard events for menu
-                    event = self._menu.create_back_event()
-                    LOGGER.debug("EVT_BUTTONDOWN: generate MENU-ESC event")
-                else:
-                    event = pygame.event.Event(EVT_BUTTONDOWN, capture=1, printer=1,
-                                               button=self.buttons)
-                    LOGGER.debug("EVT_BUTTONDOWN: generate DOUBLE buttons event")
-                self.buttons.capture.hold_repeat = False
-                self._multipress_timer.reset()
-                pygame.event.post(event)
-        else:
-            # Capture was held but printer not pressed
-            if self._menu and self._menu.is_shown():
-                # Convert HW button events to keyboard events for menu
-                event = self._menu.create_next_event()
-                LOGGER.debug("EVT_BUTTONDOWN: generate MENU-NEXT event")
-            else:
-                event = pygame.event.Event(EVT_BUTTONDOWN, capture=1, printer=0,
-                                           button=self.buttons.capture)
-                LOGGER.debug("EVT_BUTTONDOWN: generate CAPTURE button event")
-            self.buttons.capture.hold_repeat = False
-            self._multipress_timer.reset()
-            pygame.event.post(event)
-
-    def _on_button_printer_held(self):
-        """Called when the printer button is pressed.
-        """
-        if all(self.buttons.value):
-            # Printer was held while capture was pressed
-            # but don't do anything here, let capture_held handle it instead
-            pass
-        else:
-            # Printer was held but capture not pressed
-            if self._menu and self._menu.is_shown():
-                # Convert HW button events to keyboard events for menu
-                event = self._menu.create_click_event()
-                LOGGER.debug("EVT_BUTTONDOWN: generate MENU-APPLY event")
-            else:
-                event = pygame.event.Event(EVT_BUTTONDOWN, capture=0, printer=1,
-                                           button=self.buttons.printer)
-                LOGGER.debug("EVT_BUTTONDOWN: generate PRINTER event")
-            pygame.event.post(event)
-
-    @property
-    def picture_filename(self):
-        """Return the final picture file name.
-        """
-        if not self.capture_date:
-            raise EnvironmentError("The 'capture_date' attribute is not set yet")
-        return "{}_pibooth.jpg".format(self.capture_date)
-
-    def find_quit_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == pygame.QUIT:
-                return event
-        return None
-
-    def find_settings_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
-                return event
-            if event.type == EVT_BUTTONDOWN and event.capture and event.printer:
-                return event
-            if event.type == pygame.FINGERDOWN:
-                # Press but not release
-                self._fingerdown_events.append(event)
-            if event.type == pygame.FINGERUP:
-                # Resetting touch_events
-                self._fingerdown_events = []
-            if len(self._fingerdown_events) > 3:
-                # 4 fingers on the screen trigger the menu
-                self._fingerdown_events = []
-                return pygame.event.Event(EVT_BUTTONDOWN, capture=1, printer=1,
-                                          button=self.buttons)
-        return None
-
-    def find_fullscreen_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == pygame.KEYDOWN and \
-                    event.key == pygame.K_f and pygame.key.get_mods() & pygame.KMOD_CTRL:
-                return event
-        return None
-
-    def find_resize_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == pygame.VIDEORESIZE:
-                return event
-        return None
-
-    def find_capture_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == pygame.KEYDOWN and event.key == pygame.K_p:
-                return event
-            if (event.type == pygame.MOUSEBUTTONUP and event.button in (1, 2, 3)) or event.type == pygame.FINGERUP:
-                pos = get_event_pos(self._window.display_size, event)
-                rect = self._window.get_rect()
-                if pygame.Rect(0, 0, rect.width // 2, rect.height).collidepoint(pos):
-                    return event
-            if event.type == EVT_BUTTONDOWN and event.capture:
-                return event
-        return None
-
-    def find_print_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == pygame.KEYDOWN and event.key == pygame.K_e\
-                    and pygame.key.get_mods() & pygame.KMOD_CTRL:
-                return event
-            if (event.type == pygame.MOUSEBUTTONUP and event.button in (1, 2, 3)) or event.type == pygame.FINGERUP:
-                pos = get_event_pos(self._window.display_size, event)
-                rect = self._window.get_rect()
-                if pygame.Rect(rect.width // 2, 0, rect.width // 2, rect.height).collidepoint(pos):
-                    return event
-            if event.type == EVT_BUTTONDOWN and event.printer:
-                return event
-        return None
-
-    def find_print_status_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == EVT_PRINTER_TASKS_UPDATED:
-                return event
-        return None
-
-    def find_choice_event(self, events):
-        """Return the first found event if found in the list.
-        """
-        for event in events:
-            if event.type == pygame.KEYDOWN and event.key == pygame.K_LEFT:
-                return event
-            if event.type == pygame.KEYDOWN and event.key == pygame.K_RIGHT:
-                return event
-            if (event.type == pygame.MOUSEBUTTONUP and event.button in (1, 2, 3)) or event.type == pygame.FINGERUP:
-                pos = get_event_pos(self._window.display_size, event)
-                rect = self._window.get_rect()
-                if pygame.Rect(0, 0, rect.width // 2, rect.height).collidepoint(pos):
-                    event.key = pygame.K_LEFT
-                else:
-                    event.key = pygame.K_RIGHT
-                return event
-            if event.type == EVT_BUTTONDOWN:
-                if event.capture:
-                    event.key = pygame.K_LEFT
-                else:
-                    event.key = pygame.K_RIGHT
-                return event
-        return None
-
-    def main_loop(self):
-        try:
-            fps = 40
-            clock = pygame.time.Clock()
-            self._initialize()
-            self._pm.hook.pibooth_startup(cfg=self._config, app=self)
-            self._machine.set_state('wait')
-
-            while True:
-                events = list(pygame.event.get())
-
-                if self.find_quit_event(events):
-                    break
-
-                if self.find_fullscreen_event(events):
-                    self._window.toggle_fullscreen()
-
-                event = self.find_resize_event(events)
-                if event:
-                    self._window.resize(event.size)
-
-                if not self._menu and self.find_settings_event(events):
-                    self.camera.stop_preview()
-                    self.leds.off()
-                    self._menu = PiConfigMenu(self._pm, self._config, self, self._window)
-                    self._menu.show()
-                    self.leds.blink(on_time=0.1, off_time=1)
-                elif self._menu and self._menu.is_shown():
-                    self._menu.process(events)
-                elif self._menu and not self._menu.is_shown():
-                    self.leds.off()
-                    self._initialize()
-                    self._machine.set_state('wait')
-                    self._menu = None
-                else:
-                    self._machine.process(events)
-
-                pygame.display.update()
-                clock.tick(fps)  # Ensure the program will never run at more than <fps> frames per second
-
-        except Exception as ex:
-            LOGGER.error(str(ex), exc_info=True)
-            LOGGER.error(get_crash_message())
-        finally:
-            self._pm.hook.pibooth_cleanup(app=self)
-            AsyncTask.kill_all()
-            pygame.quit()
-
-
-def main():
-    """Application entry point.
-    """
-    if hasattr(multiprocessing, 'set_start_method'):
-        # Avoid use 'fork': safely forking a multithreaded process is problematic
-        multiprocessing.set_start_method('spawn')
-
-    parser = argparse.ArgumentParser(usage="%(prog)s [options]", description=pibooth.__doc__)
-
-    parser.add_argument("config_directory", nargs='?', default="~/.config/pibooth",
-                        help=u"path to configuration directory (default: %(default)s)")
-
-    parser.add_argument('--version', action='version', version=pibooth.__version__,
-                        help=u"show program's version number and exit")
-
-    parser.add_argument("--config", action='store_true',
-                        help=u"edit the current configuration and exit")
-
-    parser.add_argument("--translate", action='store_true',
-                        help=u"edit the GUI translations and exit")
-
-    parser.add_argument("--reset", action='store_true',
-                        help=u"restore the default configuration/translations and exit")
-
-    parser.add_argument("--nolog", action='store_true', default=False,
-                        help=u"don't save console output in a file (avoid filling the /tmp directory)")
-
-    group = parser.add_mutually_exclusive_group()
-    group.add_argument("-v", "--verbose", dest='logging', action='store_const', const=logging.DEBUG,
-                       help=u"report more information about operations", default=logging.INFO)
-    group.add_argument("-q", "--quiet", dest='logging', action='store_const', const=logging.WARNING,
-                       help=u"report only errors and warnings", default=logging.INFO)
-
-    options = parser.parse_args()
-
-    if not options.nolog:
-        filename = osp.join(tempfile.gettempdir(), 'pibooth.log')
-    else:
-        filename = None
-    configure_logging(options.logging, '[ %(levelname)-8s] %(name)-18s: %(message)s', filename=filename)
-
-    plugin_manager = create_plugin_manager()
-
-    # Load the configuration
-    config = PiConfigParser(osp.join(options.config_directory, "pibooth.cfg"), plugin_manager, not options.reset)
-
-    # Register plugins
-    plugin_manager.load_all_plugins(config.gettuple('GENERAL', 'plugins', 'path'),
-                                    config.gettuple('GENERAL', 'plugins_disabled', str))
-    LOGGER.info("Installed plugins: %s", ", ".join(
-        [plugin_manager.get_friendly_name(p) for p in plugin_manager.list_external_plugins()]))
-
-    # Load the languages
-    language.init(config.join_path("translations.cfg"), options.reset)
-
-    # Update configuration with plugins ones
-    plugin_manager.hook.pibooth_configure(cfg=config)
-
-    # Ensure config files are present in case of first pibooth launch
-    if not options.reset:
-        if not osp.isfile(config.filename):
-            config.save(default=True)
-        plugin_manager.hook.pibooth_reset(cfg=config, hard=False)
-
-    if options.config:
-        LOGGER.info("Editing the pibooth configuration...")
-        config.edit()
-    elif options.translate:
-        LOGGER.info("Editing the GUI translations...")
-        language.edit()
-    elif options.reset:
-        config.save(default=True)
-        plugin_manager.hook.pibooth_reset(cfg=config, hard=True)
-    else:
-        LOGGER.info("Starting the photo booth application %s", GPIO_INFO)
-        app = PiApplication(config, plugin_manager)
-        app.main_loop()
-
-
-if __name__ == '__main__':
-    main()
diff --git a/pibooth/camera/__init__.py b/pibooth/camera/__init__.py
index 52cb9f0b..e6e20f40 100644
--- a/pibooth/camera/__init__.py
+++ b/pibooth/camera/__init__.py
@@ -1,7 +1,6 @@
 # -*- coding: utf-8 -*-
 
 from pibooth.utils import LOGGER
-from pibooth.camera.base import EVT_CAMERA_PREVIEW, EVT_CAMERA_CAPTURE
 from pibooth.camera.rpi import RpiCamera, get_rpi_camera_proxy
 from pibooth.camera.gphoto import GpCamera, get_gp_camera_proxy
 from pibooth.camera.opencv import CvCamera, get_cv_camera_proxy
diff --git a/pibooth/camera/base.py b/pibooth/camera/base.py
index a6780d56..85e09c18 100644
--- a/pibooth/camera/base.py
+++ b/pibooth/camera/base.py
@@ -3,15 +3,11 @@
 import pygame
 from PIL import Image, ImageDraw
 
-from pibooth import fonts
-from pibooth.utils import AsyncTask
+from pibooth import fonts, evts
+from pibooth.tasks import AsyncTask
 from pibooth.pictures import sizing
 
 
-EVT_CAMERA_PREVIEW = pygame.USEREVENT + 3
-EVT_CAMERA_CAPTURE = pygame.USEREVENT + 4
-
-
 class BaseCamera(object):
 
     IMAGE_EFFECTS = ['none']
@@ -113,7 +109,7 @@ def preview(self, rect, flip=True):
         self._rect = pygame.Rect(rect.centerx - size[0] // 2, rect.centery - size[1] // 2, size[0], size[1])
 
         self.preview_flip = flip
-        self._worker = AsyncTask(self.get_preview_image, event=EVT_CAMERA_PREVIEW, loop=True)
+        self._worker = AsyncTask(self.get_preview_image, event=evts.EVT_PIBOOTH_CAM_PREVIEW, loop=True)
 
     def stop_preview(self):
         """Stop the preview.
@@ -144,7 +140,7 @@ def capture(self, effect=None, wait=False):
         if self._worker:
             self.stop_preview()
 
-        self._worker = AsyncTask(self.get_capture_image, (effect,), event=EVT_CAMERA_CAPTURE)
+        self._worker = AsyncTask(self.get_capture_image, (effect,), event=evts.EVT_PIBOOTH_CAM_CAPTURE)
         if wait:
             self._worker.wait()
 
diff --git a/pibooth/config/__init__.py b/pibooth/config/__init__.py
index ae3012e2..f21e83e6 100644
--- a/pibooth/config/__init__.py
+++ b/pibooth/config/__init__.py
@@ -1,3 +1,3 @@
 # -*- coding: utf-8 -*-
 
-from pibooth.config.parser import PiConfigParser
+from pibooth.config.parser import PiboothConfigParser
diff --git a/pibooth/config/default.py b/pibooth/config/default.py
index 71903470..d214dede 100644
--- a/pibooth/config/default.py
+++ b/pibooth/config/default.py
@@ -38,7 +38,7 @@ def add_default_option(section, option, default, description, menu_name=None, me
                    "Auto-start delay", [str(i) for i in range(0, 121, 5)])
 
 add_default_option("GENERAL", "debug", False,
-                   "In debug mode, exceptions are not caught, logs are more verbose, pictures are cleared at startup",
+                   "In debug mode, exceptions will stop pibooth, logs are more verbose, image placeholders at screen",
                    "Debug mode", ['True', 'False'])
 
 add_default_option("GENERAL", "plugins", '',
@@ -111,9 +111,9 @@ def add_default_option(section, option, default, description, menu_name=None, me
                    "Orientation of the final picture: 'auto', 'portrait' or 'landscape'",
                    "Orientation", ['auto', 'portrait', 'landscape'])
 
-add_default_option("PICTURE", "captures", (4, 1),
+add_default_option("PICTURE", "captures", (1, 2, 3, 4),
                    "Possible choice(s) of captures numbers (numbers between 1 to 4)",
-                   "Number of captures", ['1', '2', '3', '4'] + [str(val) for val in itertools.permutations(range(1, 5), 2)])
+                   "Number of captures", ['(1, 2, 3, 4)', '1', '2', '3', '4'] + [str(val) for val in itertools.permutations(range(1, 5), 2)])
 
 add_default_option("PICTURE", "captures_effects", 'none',
                    "Effect applied to the captures (list of quoted names accepted)")
diff --git a/pibooth/config/parser.py b/pibooth/config/parser.py
index 9b716d51..cbcc914a 100644
--- a/pibooth/config/parser.py
+++ b/pibooth/config/parser.py
@@ -14,7 +14,7 @@
 from pibooth.utils import LOGGER, open_text_editor
 
 
-class PiConfigParser(RawConfigParser):
+class PiboothConfigParser(RawConfigParser):
 
     """Class to parse and store the configuration values.
     The following attributes are available for use in plugins:
@@ -24,7 +24,7 @@ class PiConfigParser(RawConfigParser):
     """
 
     def __init__(self, filename, plugin_manager, load=True):
-        super(PiConfigParser, self).__init__()
+        super(PiboothConfigParser, self).__init__()
         self._pm = plugin_manager
         self.filename = osp.abspath(osp.expanduser(filename))
 
@@ -168,7 +168,7 @@ def get(self, section, option, **kwargs):
         :rtype: str
         """
         if self.has_section(section) and self.has_option(section, option):
-            return super(PiConfigParser, self).get(section, option, **kwargs)
+            return super(PiboothConfigParser, self).get(section, option, **kwargs)
         return str(DEFAULT[section][option][0])
 
     def set(self, section, option, value=None):
@@ -183,7 +183,7 @@ def set(self, section, option, value=None):
         """
         if not self.has_section(section):
             self.add_section(section)
-        super(PiConfigParser, self).set(section, option, value)
+        super(PiboothConfigParser, self).set(section, option, value)
 
     def gettyped(self, section, option):
         """Get a value from config and try to convert it in a native Python
diff --git a/pibooth/evts.py b/pibooth/evts.py
new file mode 100644
index 00000000..69024d4e
--- /dev/null
+++ b/pibooth/evts.py
@@ -0,0 +1,163 @@
+# -*- coding: utf-8 -*-
+
+import pygame
+from pygame._sdl2 import touch
+import pygame_menu as pgm
+from pibooth.utils import LOGGER
+
+EVT_PIBOOTH_BTN_CAPTURE = pygame.USEREVENT + 201
+EVT_PIBOOTH_BTN_PRINT = pygame.USEREVENT + 202
+EVT_PIBOOTH_BTN_SETTINGS = pygame.USEREVENT + 203
+EVT_PIBOOTH_PRINTER_UPDATE = pygame.USEREVENT + 204
+EVT_PIBOOTH_CAM_PREVIEW = pygame.USEREVENT + 205
+EVT_PIBOOTH_CAM_CAPTURE = pygame.USEREVENT + 206
+
+
+def post(*args, **kwargs):
+    """Post an envent to be processed later by tha main loop. Usefull
+    for thread safe action on view.
+
+    The default implementation use the Pygame event mechanism.
+    """
+    return pygame.event.post(pygame.event.Event(*args, **kwargs))
+
+
+def post_button_capture_event():
+    """Post EVT_PIBOOTH_BTN_CAPTURE.
+    """
+    LOGGER.debug("Event triggered: EVT_PIBOOTH_BTN_CAPTURE")
+    return post(EVT_PIBOOTH_BTN_CAPTURE)
+
+
+def post_button_print_event():
+    """Post EVT_PIBOOTH_BTN_PRINT.
+    """
+    LOGGER.debug("Event triggered: EVT_PIBOOTH_BTN_PRINT")
+    return post(EVT_PIBOOTH_BTN_PRINT)
+
+
+def post_button_settings_event():
+    """Post EVT_PIBOOTH_BTN_SETTINGS.
+    """
+    LOGGER.debug("Event triggered: EVT_PIBOOTH_BTN_SETTINGS")
+    return post(EVT_PIBOOTH_BTN_SETTINGS)
+
+
+def get_event_pos(display_size, event):
+    """
+    Return the position from finger or mouse event on x-axis and y-axis (x, y).
+
+    :param display_size: size of display for relative positioning in finger events
+    :param event: pygame event object
+
+    :return: position (x, y) in px
+    :rtype: tuple
+    """
+    if event.type in (pygame.FINGERDOWN, pygame.FINGERMOTION, pygame.FINGERUP):
+        finger_pos = (event.x * display_size[0], event.y * display_size[1])
+        return finger_pos
+    return event.pos
+
+
+def get_top_visible(sprites, from_layers=(1, 2, 3, 4)):
+    """Return the top sprite (last of the list) which is visible.
+
+    :param sprites: sprites list
+    :type sprites: list
+    :param from_layers: layers to belong to
+    :type from_layers: list
+    """
+    for sp in reversed(sprites):
+        if sp.visible and sp.layer in from_layers:
+            return sp
+    return None
+
+
+def is_fullscreen_event(event):
+    """Return True if fullscreen event.
+    """
+    return event.type == pygame.KEYDOWN and \
+        event.key == pygame.K_f and pygame.key.get_mods() & pygame.KMOD_CTRL
+
+
+def is_fingers_event(event, nbr_fingers):
+    """Return True if screen is touched with the number of fingers (or more).
+    """
+    return event.type == pygame.FINGERDOWN and touch.get_num_fingers(event.touch_id) >= nbr_fingers
+
+
+def is_button_settings_event(event):
+    """Return True if settings event.
+    """
+    return event.type == EVT_PIBOOTH_BTN_SETTINGS
+
+
+def is_button_capture_event(event):
+    """Return True if capture button event.
+    """
+    return event.type == EVT_PIBOOTH_BTN_CAPTURE
+
+
+def is_button_print_event(event):
+    """Return True if capture button event.
+    """
+    return event.type == EVT_PIBOOTH_BTN_PRINT
+
+
+def is_printer_status_event(event):
+    """Return True if printer status event.
+    """
+    return event.type == EVT_PIBOOTH_PRINTER_UPDATE
+
+
+def is_camera_capture_event(event):
+    """Return True if camera capture event.
+    """
+    return event.type == EVT_PIBOOTH_CAM_CAPTURE
+
+
+def is_camera_preview_event(event):
+    """Return True if camera preview event.
+    """
+    return event.type == EVT_PIBOOTH_CAM_PREVIEW
+
+
+def find_event(events, event_type):
+    """Return the first found event if found in the list.
+    """
+    for event in events:
+        if event.type == event_type:
+            return event
+    return None
+
+
+def create_click_event(button=True):
+    """Create a pygame event to click on the currently selected
+    widget on the menu. If the widget is a button, ENTER event
+    is created, else LEFT event is created.
+    """
+    if button:
+        event = pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_APPLY,
+                                   unicode='\r', mod=0, scancode=36,
+                                   window=None, test=True)
+    else:
+        event = pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_RIGHT,
+                                   unicode=u'\uf703', mod=0, scancode=124,
+                                   window=None, test=True)
+    return event
+
+
+def create_next_event():
+    """Create a pygame event to select the next widget.
+    """
+    return pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_MOVE_UP,
+                              unicode=u'\uf701', mod=0, scancode=125,
+                              window=None, test=True)
+
+
+def create_back_event():
+    """Create a pygame event to back to the previous menu.
+    """
+    return pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_BACK,
+                              unicode=u'\x1b', mod=0, scancode=53,
+                              window=None, test=True)
diff --git a/pibooth/fonts/__init__.py b/pibooth/fonts/__init__.py
index fe89b575..97e5d77f 100644
--- a/pibooth/fonts/__init__.py
+++ b/pibooth/fonts/__init__.py
@@ -100,7 +100,7 @@ def get_pygame_font(text, font_name, max_width, max_height):
         k = (start + end) // 2
         font = pygame.font.Font(get_filename(font_name), k)
         font_size = font.size(text)
-        if font_size[0] > max_width or font_size[1] > max_height:
+        if font_size[0] >= max_width or font_size[1] >= max_height:
             end = k
         else:
             start = k + 1
diff --git a/pibooth/language.py b/pibooth/language.py
index db2506ca..8a031885 100644
--- a/pibooth/language.py
+++ b/pibooth/language.py
@@ -112,7 +112,7 @@
         'oops': "Sajnos valami hiba történt",
     },
     'it': {
-        'intro': "Scatta una foto", 
+        'intro': "Scatta una foto",
         'intro_print': 'Oppure puoi\nstampare\nquesta foto',
         'choose': "Scegli il formato",
         '1': "1 foto",
@@ -159,6 +159,22 @@
         'finished': "Takk",
         'oops': "Ops, noe gikk galt",
     },
+    'pt': {
+        'intro': "Tira uma foto",
+        'intro_print': 'Ainda podes\nimprimir esta\nfoto',
+        'choose': "Escolhe o teu formato",
+        '1': "1 foto",
+        '2': "2 fotos",
+        '3': "3 fotos",
+        '4': "4 fotos",
+        'chosen': "Vamos!",
+        'smile': "Sorri!",
+        'processing': "A processar...",
+        'print': "Imprimir foto?",
+        'print_forget': "Por favor\nesqueçe-te\ndesta foto",
+        'finished': "Obrigado!",
+        'oops': "Oops, ocorreu um erro.",
+    },
 }
 
 
diff --git a/pibooth/pictures/__init__.py b/pibooth/pictures/__init__.py
index 64753d11..7d14e9f0 100644
--- a/pibooth/pictures/__init__.py
+++ b/pibooth/pictures/__init__.py
@@ -13,6 +13,16 @@
 PORTRAIT = 'portrait'
 LANDSCAPE = 'landscape'
 
+ALIGN_TOP_LEFT = 'top-left'
+ALIGN_TOP_CENTER = 'top-center'
+ALIGN_TOP_RIGHT = 'top-right'
+ALIGN_CENTER_LEFT = 'center-left'
+ALIGN_CENTER = 'center'
+ALIGN_CENTER_RIGHT = 'center-right'
+ALIGN_BOTTOM_LEFT = 'bottom-left'
+ALIGN_BOTTOM_CENTER = 'bottom-center'
+ALIGN_BOTTOM_RIGHT = 'bottom-right'
+
 
 def get_filename(name):
     """Return absolute path to a picture located in the current package.
@@ -27,7 +37,7 @@ def get_filename(name):
 
 
 def colorize_pil_image(pil_image, color, bg_color=None):
-    """Convert a picto in white to the corresponding color.
+    """Create a "colorized" copy of a PIL image in white to the corresponding color.
 
     :param pil_image: PIL image to be colorized
     :type pil_image: :py:class:`PIL.Image`
@@ -35,6 +45,9 @@ def colorize_pil_image(pil_image, color, bg_color=None):
     :type color: tuple
     :param bg_color: RGB color to use for the picto's background
     :type bg_color: tuple
+
+    :return: new colorized Image instance
+    :rtype: object
     """
     if not bg_color:
         bg_color = (abs(color[0] - 255), abs(color[1] - 255), abs(color[2] - 255))
@@ -45,20 +58,53 @@ def colorize_pil_image(pil_image, color, bg_color=None):
     return new_pil_image
 
 
-def get_pygame_main_color(surface):
-    """Return the main color of the given pygame surface.
+def colorize_pygame_image(surface, color):
+    """Create a "colorized" copy of a surface (replaces RGB values with the given color, preserving
+    the per-pixel alphas of original).
+
+    :param surface: surface to create a colorized copy of
+    :type surface: object
+    :param color: RGB color to use (original alpha values are preserved)
+    :type color: tuple
+
+    :return: new colorized Surface instance
+    :rtype: object
     """
-    monopixel_surface = pygame.transform.scale(surface, (1, 1))
-    return tuple(monopixel_surface.get_at((0, 0)))
+    surface = surface.copy()
+    # zero out RGB values
+    surface.fill((0, 0, 0, 255), None, pygame.BLEND_RGBA_MULT)
+    # add in new RGB values
+    surface.fill(color[0:3] + (0,), None, pygame.BLEND_RGBA_ADD)
+    return surface
 
 
-def get_pygame_image(name, size=None, antialiasing=True, hflip=False, vflip=False,
-                     crop=False, angle=0, color=(255, 255, 255), bg_color=None):
-    """Return a Pygame image. If a size is given, the image is
-    resized keeping the original image's aspect ratio.
+def load_pygame_image(filename):
+    """Load Pygame image from located in `pibooth.pictures.assets` folder
+    or from path.
 
-    :param name: name of an image located in language folders
-    :type name: str
+    :param filename: name of asset (with extension) or path to an image file
+    :type filename: str
+
+    :return: Pygame surface
+    :rtype: object
+    """
+    if osp.isfile(filename):
+        path = filename
+    else:
+        path = get_filename(filename)
+
+    if osp.isfile(path):
+        return pygame.image.load(path).convert_alpha()
+    raise ValueError(f"No such image file '{filename}'")
+
+
+def transform_pygame_image(surface, size, antialiasing=True, hflip=False, vflip=False,
+                           angle=0, crop=False, color=None):
+    """Resize a Pygame surface, the image is resized keeping the original
+    surface's aspect ratio.
+
+    :param surface: Pygame surface to resize
+    :type surface: object
     :param size: resize image to this size
     :type size: tuple
     :param antialiasing: use antialiasing algorithm when resize
@@ -67,67 +113,126 @@ def get_pygame_image(name, size=None, antialiasing=True, hflip=False, vflip=Fals
     :type hflip: bool
     :param vflip: apply a vertical flip
     :type vflip: bool
-    :param crop: crop image to fit aspect ration of the size
+    :param crop: crop image to fit the size keeping aspect ratio
     :type crop: bool
     :param angle: angle of rotation of the image
     :type angle: int
-    :param color: recolorize the image with this RGB color
+    :param color: recolorize image
     :type color: tuple
-    :param bg_color: recolorize the image background with this RGB color
-    :type bg_color: tuple
 
     :return: pygame.Surface with image
     :rtype: object
     """
-    path = get_filename(name)
-    if not size and not color:
-        image = pygame.image.load(path).convert()
+    if crop:
+        resize_type = 'outer'
     else:
-        if osp.isfile(path):
-            pil_image = Image.open(path)
-        else:
-            pil_image = Image.new('RGBA', size, (0, 0, 0, 0))
-
-        if color:
-            pil_image = colorize_pil_image(pil_image, color, bg_color)
+        resize_type = 'inner'
 
-        if crop:
-            pil_image = pil_image.crop(sizing.new_size_by_croping_ratio(pil_image.size, size))
-        pil_image = pil_image.resize(sizing.new_size_keep_aspect_ratio(pil_image.size, size),
-                                     Image.ANTIALIAS if antialiasing else Image.NEAREST)
+    if angle != 0:
+        surface = pygame.transform.rotate(surface, angle)
 
-        image = pygame.image.frombuffer(pil_image.tobytes(), pil_image.size, pil_image.mode)
+    if size != surface.get_size():
+        if antialiasing:
+            image = pygame.transform.smoothscale(
+                surface, sizing.new_size_keep_aspect_ratio(surface.get_size(), size, resize_type))
+        else:
+            image = pygame.transform.scale(surface, sizing.new_size_keep_aspect_ratio(
+                surface.get_size(), size, resize_type))
+    else:
+        image = surface.copy()
+
+    if crop and size != image.get_size():
+        # Crop image to fill surface
+        new_surface = pygame.Surface(size, pygame.SRCALPHA, 32)
+        new_surface.blit(image, (0, 0), sizing.new_size_by_croping_ratio(image.get_rect().size, size))
+        image = new_surface
+    elif size != image.get_size():
+        # Center image on surface
+        new_surface = pygame.Surface(size, pygame.SRCALPHA, 32)
+        new_surface.blit(image, image.get_rect(center=new_surface.get_rect().center))
+        image = new_surface
 
     if hflip or vflip:
         image = pygame.transform.flip(image, hflip, vflip)
-    if angle != 0:
-        image = pygame.transform.rotate(image, angle)
+    if color:
+        image = colorize_pygame_image(image, color)
     return image
 
 
-def get_pygame_layout_image(text_color, bg_color, layout_number, size):
-    """Generate the layout image with the corresponding text.
+def text_to_pygame_image(text, size, color, align='center', bg_color=None):
+    """Return a surface the text fit inside.
+
+    The ``align`` parameter can be one of:
+       * top-left
+       * top-center
+       * top-right
+       * center-left
+       * center
+       * center-right
+       * bottom-left
+       * bottom-center
+       * bottom-right
+    """
+    lines = text.splitlines()
+    surface = pygame.Surface(size, pygame.SRCALPHA, 32)
+
+    font = fonts.get_pygame_font(max(lines, key=len), fonts.CURRENT, size[0], size[1] // len(lines))
+    for i, line in enumerate(lines):
+        text_surface = font.render(line, True, color)
+
+        loc = {}
 
-    :param text_color: RGB color for texts
+        if align.endswith('left'):
+            loc['left'] = 0
+        elif align.endswith('center'):
+            loc['centerx'] = surface.get_rect().centerx
+        elif align.endswith('right'):
+            loc['right'] = surface.get_rect().right
+        else:
+            raise ValueError("Invalid horizontal alignment '{}'".format(align))
+
+        line_height = text_surface.get_rect().height
+        if align.startswith('top'):
+            loc['top'] = i * line_height
+        elif align.startswith('center'):
+            loc['top'] = surface.get_rect().centery - len(lines) * line_height // 2 + i * line_height
+        elif align.startswith('bottom'):
+            loc['bottom'] = surface.get_rect().bottom - (len(lines) - 1 - i) * line_height
+        else:
+            raise ValueError("Invalid vertical alignment '{}'".format(align))
+
+        if bg_color:
+            bg_surface = pygame.Surface(text_surface.get_size(), pygame.SRCALPHA, 32)
+            bg_surface.fill(bg_color)
+            surface.blit(bg_surface, text_surface.get_rect(**loc))
+
+        surface.blit(text_surface, text_surface.get_rect(**loc))
+    return surface
+
+
+def get_layout_asset(index, text_color, bg_color):
+    """Return the layout image with the corresponding text.
+
+    :param index: number of captures on the layout
+    :type index: int
+    :param text_color: RGB color for text
     :type text_color: tuple
-    :param layout_number: number of captures on the layout
-    :type layout_number: int
-    :param size: maximum size of the layout surface
-    :type size: tuple
+    :param bg_color: RGB color for asset image
+    :type bg_color: tuple
 
     :return: surface
     :rtype: :py:class:`pygame.Surface`
     """
-    layout_image = get_pygame_image("layout{0}.png".format(layout_number),
-                                    size, color=text_color, bg_color=bg_color)
-    text = language.get_translated_text(str(layout_number))
+    layout_image = colorize_pygame_image(
+        load_pygame_image(f"layout{index}.png"), bg_color)
+    text = language.get_translated_text(str(index))
     if text:
         rect = layout_image.get_rect()
         rect = pygame.Rect(rect.x + rect.width * 0.3 / 2,
                            rect.y + rect.height * 0.76,
                            rect.width * 0.7, rect.height * 0.20)
         text_font = fonts.get_pygame_font(text, fonts.CURRENT, rect.width, rect.height)
-        surface = text_font.render(text, True, bg_color)
+        surface = text_font.render(text, True, text_color)
         layout_image.blit(surface, surface.get_rect(center=rect.center))
     return layout_image
 
@@ -173,6 +278,9 @@ def get_picture_factory(captures, orientation=AUTO, paper_format=(4, 6), force_p
     :type force_pil: bool
     :param dpi: dot-per-inche resolution
     :type dpi: int
+
+    :return: PictureFactory instance
+    :rtype: object
     """
     assert orientation in (AUTO, PORTRAIT, LANDSCAPE), "Unknown orientation '{}'".format(orientation)
     if orientation == AUTO:
diff --git a/pibooth/pictures/assets/arrow_double.png b/pibooth/pictures/assets/arrow_double.png
new file mode 100644
index 00000000..5cc7d146
Binary files /dev/null and b/pibooth/pictures/assets/arrow_double.png differ
diff --git a/pibooth/pictures/assets/check.png b/pibooth/pictures/assets/check.png
new file mode 100644
index 00000000..74930cd4
Binary files /dev/null and b/pibooth/pictures/assets/check.png differ
diff --git a/pibooth/pictures/assets/dot.png b/pibooth/pictures/assets/dot.png
new file mode 100644
index 00000000..b1c1e1f7
Binary files /dev/null and b/pibooth/pictures/assets/dot.png differ
diff --git a/pibooth/pictures/assets/dot_filled.png b/pibooth/pictures/assets/dot_filled.png
new file mode 100644
index 00000000..6af91de0
Binary files /dev/null and b/pibooth/pictures/assets/dot_filled.png differ
diff --git a/pibooth/pictures/assets/failure.png b/pibooth/pictures/assets/failure.png
new file mode 100644
index 00000000..d183b86b
Binary files /dev/null and b/pibooth/pictures/assets/failure.png differ
diff --git a/pibooth/pictures/assets/finished_left.png b/pibooth/pictures/assets/finish_left.png
similarity index 100%
rename from pibooth/pictures/assets/finished_left.png
rename to pibooth/pictures/assets/finish_left.png
diff --git a/pibooth/pictures/assets/finished_right.png b/pibooth/pictures/assets/finish_right.png
similarity index 100%
rename from pibooth/pictures/assets/finished_right.png
rename to pibooth/pictures/assets/finish_right.png
diff --git a/pibooth/pictures/assets/layout1.png b/pibooth/pictures/assets/layout1.png
index 2b0313a4..993aecc0 100644
Binary files a/pibooth/pictures/assets/layout1.png and b/pibooth/pictures/assets/layout1.png differ
diff --git a/pibooth/pictures/assets/layout2.png b/pibooth/pictures/assets/layout2.png
index 0d0abc6e..0783ed80 100644
Binary files a/pibooth/pictures/assets/layout2.png and b/pibooth/pictures/assets/layout2.png differ
diff --git a/pibooth/pictures/assets/layout3.png b/pibooth/pictures/assets/layout3.png
index ac84f504..10cdd0ea 100644
Binary files a/pibooth/pictures/assets/layout3.png and b/pibooth/pictures/assets/layout3.png differ
diff --git a/pibooth/pictures/assets/layout4.png b/pibooth/pictures/assets/layout4.png
index 04c88027..c8252bf3 100644
Binary files a/pibooth/pictures/assets/layout4.png and b/pibooth/pictures/assets/layout4.png differ
diff --git a/pibooth/pictures/assets/origin/printer.xcf b/pibooth/pictures/assets/origin/printer.xcf
deleted file mode 100644
index b6bcda8c..00000000
Binary files a/pibooth/pictures/assets/origin/printer.xcf and /dev/null differ
diff --git a/pibooth/pictures/assets/origin/touch.xcf b/pibooth/pictures/assets/origin/touch.xcf
new file mode 100644
index 00000000..9d527b8e
Binary files /dev/null and b/pibooth/pictures/assets/origin/touch.xcf differ
diff --git a/pibooth/pictures/assets/printer.png b/pibooth/pictures/assets/printer.png
index decd0e3c..91b4df46 100644
Binary files a/pibooth/pictures/assets/printer.png and b/pibooth/pictures/assets/printer.png differ
diff --git a/pibooth/pictures/assets/printer_failure.png b/pibooth/pictures/assets/printer_failure.png
deleted file mode 100644
index 6e5fae04..00000000
Binary files a/pibooth/pictures/assets/printer_failure.png and /dev/null differ
diff --git a/pibooth/pictures/assets/hand.png b/pibooth/pictures/assets/touch.png
similarity index 100%
rename from pibooth/pictures/assets/hand.png
rename to pibooth/pictures/assets/touch.png
diff --git a/pibooth/pictures/assets/camera.png b/pibooth/pictures/assets/touch_camera.png
similarity index 100%
rename from pibooth/pictures/assets/camera.png
rename to pibooth/pictures/assets/touch_camera.png
diff --git a/pibooth/pictures/assets/printer_touch.png b/pibooth/pictures/assets/touch_print.png
similarity index 100%
rename from pibooth/pictures/assets/printer_touch.png
rename to pibooth/pictures/assets/touch_print.png
diff --git a/pibooth/pictures/assets/touch_thumb.png b/pibooth/pictures/assets/touch_thumb.png
new file mode 100644
index 00000000..eae12ba1
Binary files /dev/null and b/pibooth/pictures/assets/touch_thumb.png differ
diff --git a/pibooth/plugins/__init__.py b/pibooth/plugins/__init__.py
index 036a7c2c..632024c6 100644
--- a/pibooth/plugins/__init__.py
+++ b/pibooth/plugins/__init__.py
@@ -55,18 +55,20 @@ def load_all_plugins(self, paths, disabled=None):
 
         :param paths: list of Python module/package paths to load
         :type paths: list
-        :param disabled: list of plugins name to be disabled after loaded
+        :param disabled: list of plugins name to be disabled after loaded or 'all'
         :type disabled: list
         """
-        # Load plugins declared by setuptools entry points
-        self.load_setuptools_entrypoints(hookspecs.hookspec.project_name)
-
         plugins = []
-        for path in paths:
-            plugin = load_module(path)
-            if plugin:
-                LOGGER.debug("Plugin found at '%s'", path)
-                plugins.append(plugin)
+
+        if disabled != 'all':
+            # Load plugins declared by setuptools entry points
+            self.load_setuptools_entrypoints(hookspecs.hookspec.project_name)
+
+            for path in paths:
+                plugin = load_module(path)
+                if plugin:
+                    LOGGER.debug("Plugin found at '%s'", path)
+                    plugins.append(plugin)
 
         plugins += [LightsPlugin(self),  # Last called
                     ViewPlugin(self),
@@ -82,7 +84,7 @@ def load_all_plugins(self, paths, disabled=None):
         self.check_pending()
 
         # Disable unwanted plugins
-        if disabled:
+        if disabled and disabled != 'all':
             for name in disabled:
                 self.unregister(name=name)
 
diff --git a/pibooth/plugins/camera_plugin.py b/pibooth/plugins/camera_plugin.py
index ed615b71..a6b2b24d 100644
--- a/pibooth/plugins/camera_plugin.py
+++ b/pibooth/plugins/camera_plugin.py
@@ -2,7 +2,6 @@
 
 import time
 import math
-import pygame
 import pibooth
 from pibooth import camera
 from pibooth.language import get_translated_text
@@ -14,6 +13,8 @@ class CameraPlugin(object):
     """Plugin to manage the camera captures.
     """
 
+    __name__ = 'pibooth-core:camera'
+
     def __init__(self, plugin_manager):
         self._pm = plugin_manager
         self.timer = PollingTimer()
@@ -25,7 +26,7 @@ def pibooth_setup_camera(self, cfg):
         cam = outcome.get_result()
 
         if not cam:
-            LOGGER.debug("Fallback to pibooth default camera management system")
+            LOGGER.debug("Use pibooth camera management system")
             cam = camera.find_camera()
 
         cam.initialize(cfg.gettuple('CAMERA', 'iso', (int, str), 2),
@@ -59,15 +60,6 @@ def state_wait_enter(self, app):
     def state_wait_exit(self):
         self.count = 0
 
-    @pibooth.hookimpl
-    def state_choose_do(self, app, events):
-        event = app.find_choice_event(events)
-        if event:
-            if event.key == pygame.K_LEFT:
-                app.capture_nbr = app.capture_choices[0]
-            elif event.key == pygame.K_RIGHT:
-                app.capture_nbr = app.capture_choices[1]
-
     @pibooth.hookimpl
     def state_preview_enter(self, cfg, app, win):
         LOGGER.info("Show preview before next capture")
diff --git a/pibooth/plugins/lights_plugin.py b/pibooth/plugins/lights_plugin.py
index 005426c7..a2109655 100644
--- a/pibooth/plugins/lights_plugin.py
+++ b/pibooth/plugins/lights_plugin.py
@@ -1,13 +1,15 @@
 # -*- coding: utf-8 -*-
 
-import time
 import pibooth
+from pibooth import evts
 
 
 class LightsPlugin(object):
     """Plugin to manage the lights via GPIO.
     """
 
+    __name__ = 'pibooth-core:lights'
+
     def __init__(self, plugin_manager):
         self._pm = plugin_manager
         self.blink_time = 0.3
@@ -23,13 +25,9 @@ def state_wait_enter(self, app):
 
     @pibooth.hookimpl
     def state_wait_do(self, app, events):
-        if app.find_print_event(events) and app.previous_picture_file and app.printer.is_ready():
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT) and app.previous_picture_file and app.printer.is_ready():
             if app.count.remaining_duplicates <= 0:
                 app.leds.printer.off()
-            else:
-                app.leds.printer.on()
-                time.sleep(1)  # Just to let the LED switched on
-                app.leds.blink(on_time=self.blink_time, off_time=self.blink_time)
 
         if not app.previous_picture_file and app.leds.printer._controller:  # _controller == blinking
             app.leds.printer.off()
@@ -44,16 +42,8 @@ def state_choose_enter(self, app):
 
     @pibooth.hookimpl
     def state_choose_exit(self, app):
-        if app.capture_nbr == app.capture_choices[0]:
-            app.leds.capture.on()
-            app.leds.printer.off()
-        elif app.capture_nbr == app.capture_choices[1]:
-            app.leds.printer.on()
-            app.leds.capture.off()
-
-    @pibooth.hookimpl
-    def state_chosen_exit(self, app):
-        app.leds.off()
+        app.leds.capture.off()
+        app.leds.printer.off()
 
     @pibooth.hookimpl
     def state_print_enter(self, app):
@@ -61,7 +51,7 @@ def state_print_enter(self, app):
 
     @pibooth.hookimpl
     def state_print_do(self, app, events):
-        if app.find_print_event(events):
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT):
             app.leds.printer.on()
             app.leds.capture.off()
 
diff --git a/pibooth/plugins/picture_plugin.py b/pibooth/plugins/picture_plugin.py
index c9a97174..cbcb7057 100644
--- a/pibooth/plugins/picture_plugin.py
+++ b/pibooth/plugins/picture_plugin.py
@@ -6,7 +6,9 @@
 from concurrent import futures
 from datetime import datetime
 import pibooth
-from pibooth.utils import LOGGER, PollingTimer, AsyncTask
+from pibooth import evts
+from pibooth.utils import LOGGER, PollingTimer
+from pibooth.tasks import AsyncTask
 from pibooth.pictures import get_picture_factory
 
 
@@ -32,6 +34,8 @@ class PicturePlugin(object):
     """Plugin to build the final picture.
     """
 
+    __name__ = 'pibooth-core:picture'
+
     def __init__(self, plugin_manager):
         self._pm = plugin_manager
         self.timer = PollingTimer()
@@ -55,12 +59,13 @@ def pibooth_setup_picture_factory(self, cfg, opt_index, factory):
         outcome = yield  # all corresponding hookimpls are invoked here
         factory = outcome.get_result() or factory
 
+        nbr_capture_choices = len(cfg.gettuple('PICTURE', 'captures', int))
         factory.set_margin(cfg.getint('PICTURE', 'margin_thick'))
 
-        backgrounds = cfg.gettuple('PICTURE', 'backgrounds', ('color', 'path'), 2)
+        backgrounds = cfg.gettuple('PICTURE', 'backgrounds', ('color', 'path'), nbr_capture_choices)
         factory.set_background(backgrounds[opt_index])
 
-        overlays = cfg.gettuple('PICTURE', 'overlays', 'path', 2)
+        overlays = cfg.gettuple('PICTURE', 'overlays', 'path', nbr_capture_choices)
         if overlays[opt_index]:
             factory.set_overlay(overlays[opt_index])
 
@@ -153,7 +158,7 @@ def state_processing_exit(self, cfg, app):
 
     @pibooth.hookimpl
     def state_print_do(self, cfg, app, events):
-        if app.find_capture_event(events):
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_CAPTURE):
 
             LOGGER.info("Moving the picture in the forget folder")
             for savedir in cfg.gettuple('GENERAL', 'directory', 'path'):
diff --git a/pibooth/plugins/printer_plugin.py b/pibooth/plugins/printer_plugin.py
index 305bdd97..3530dec7 100644
--- a/pibooth/plugins/printer_plugin.py
+++ b/pibooth/plugins/printer_plugin.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 
 import pibooth
+from pibooth import evts
 from pibooth.utils import LOGGER
 
 
@@ -9,6 +10,8 @@ class PrinterPlugin(object):
     """Plugin to manage the printer.
     """
 
+    __name__ = 'pibooth-core:printer'
+
     def __init__(self, plugin_manager):
         self._pm = plugin_manager
 
@@ -31,7 +34,7 @@ def state_failsafe_enter(self, cfg, app):
 
     @pibooth.hookimpl
     def state_wait_do(self, cfg, app, events):
-        if app.find_print_event(events) and app.previous_picture_file and app.printer.is_installed():
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT) and app.previous_picture_file and app.printer.is_installed():
 
             if app.count.remaining_duplicates <= 0:
                 LOGGER.warning("Too many duplicates sent to the printer (%s max)",
@@ -61,5 +64,5 @@ def state_processing_do(self, cfg, app):
 
     @pibooth.hookimpl
     def state_print_do(self, cfg, app, events):
-        if app.find_print_event(events) and app.previous_picture_file:
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT) and app.previous_picture_file:
             self.print_picture(cfg, app)
diff --git a/pibooth/plugins/view_plugin.py b/pibooth/plugins/view_plugin.py
index 5f245b11..6eb6d178 100644
--- a/pibooth/plugins/view_plugin.py
+++ b/pibooth/plugins/view_plugin.py
@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 
 import pibooth
-from pibooth import camera
+from pibooth import evts
 from pibooth.utils import LOGGER, get_crash_message, PollingTimer
 
 
@@ -10,10 +10,11 @@ class ViewPlugin(object):
     """Plugin to manage the pibooth window dans transitions.
     """
 
+    __name__ = 'pibooth-core:view'
+
     def __init__(self, plugin_manager):
         self._pm = plugin_manager
         self.count = 0
-        self.count_flash = 0
         self.capture_finished = False
         self.forgotten = False
         # Seconds to display the failed message
@@ -24,16 +25,13 @@ def __init__(self, plugin_manager):
         self.choose_timer = PollingTimer(30)
         # Seconds to display the selected layout
         self.layout_timer = PollingTimer(4)
-        # Seconds between each flash
-        self.flash_timer = PollingTimer(0.05)
-        # Seconds to display the selected layout
+        # Seconds to display the print view
         self.print_view_timer = PollingTimer(0)
-        # Seconds to display the selected layout
+        # Seconds to display the finished view
         self.finish_timer = PollingTimer(1)
 
     @pibooth.hookimpl
-    def state_failsafe_enter(self, win):
-        win.show_oops()
+    def state_failsafe_enter(self):
         self.failed_view_timer.start()
         LOGGER.error(get_crash_message())
 
@@ -51,32 +49,28 @@ def state_wait_enter(self, cfg, app, win):
         else:
             previous_picture = app.previous_picture
 
-        win.show_intro(previous_picture, app.printer.is_ready()
-                       and app.count.remaining_duplicates > 0)
+        win.scene.set_image(previous_picture)
+        win.scene.update_print_action(app.previous_picture and app.printer.is_ready()
+                                      and app.count.remaining_duplicates > 0)
         if app.printer.is_installed():
             win.set_print_number(len(app.printer.get_all_tasks()), not app.printer.is_ready())
 
     @pibooth.hookimpl
     def state_wait_do(self, app, win, events):
         if app.previous_animated and self.animated_frame_timer.is_timeout():
-            previous_picture = next(app.previous_animated)
-            win.show_intro(previous_picture, app.printer.is_ready()
-                           and app.count.remaining_duplicates > 0)
+            win.scene.update_print_action(app.printer.is_ready() and app.count.remaining_duplicates > 0)
             self.animated_frame_timer.start()
-        else:
-            previous_picture = app.previous_picture
+            win.scene.set_image(next(app.previous_animated))
 
-        event = app.find_print_status_event(events)
-        if event and app.printer.is_installed():
-            win.set_print_number(len(event.tasks), not app.printer.is_ready())
+        if evts.find_event(events, evts.EVT_PIBOOTH_PRINTER_UPDATE) and app.printer.is_installed():
+            win.set_print_number(len(app.printer.get_all_tasks()), not app.printer.is_ready())
 
-        if app.find_print_event(events) or (win.get_image() and not previous_picture):
-            win.show_intro(previous_picture, app.printer.is_ready()
-                           and app.count.remaining_duplicates > 0)
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT):
+            win.scene.update_print_action(app.printer.is_ready() and app.count.remaining_duplicates > 0)
 
     @pibooth.hookimpl
     def state_wait_validate(self, cfg, app, events):
-        if app.find_capture_event(events):
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_CAPTURE):
             if len(app.capture_choices) > 1:
                 return 'choose'
             if cfg.getfloat('WINDOW', 'chosen_delay') > 0:
@@ -84,17 +78,23 @@ def state_wait_validate(self, cfg, app, events):
             return 'preview'
 
     @pibooth.hookimpl
-    def state_wait_exit(self, win):
+    def state_wait_exit(self):
         self.count = 0
-        win.show_image(None)  # Clear currently displayed image
 
     @pibooth.hookimpl
     def state_choose_enter(self, app, win):
         LOGGER.info("Show picture choice (nothing selected)")
         win.set_print_number(0, False)  # Hide printer status
-        win.show_choice(app.capture_choices)
+        win.scene.set_choices(app.capture_choices)
         self.choose_timer.start()
 
+    @pibooth.hookimpl
+    def state_choose_do(self, app, win, events):
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_CAPTURE):
+            app.capture_nbr = win.scene.get_selection()
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT):
+            win.scene.next()
+
     @pibooth.hookimpl
     def state_choose_validate(self, cfg, app):
         if app.capture_nbr:
@@ -108,7 +108,7 @@ def state_choose_validate(self, cfg, app):
     @pibooth.hookimpl
     def state_chosen_enter(self, cfg, app, win):
         LOGGER.info("Show picture choice (%s captures selected)", app.capture_nbr)
-        win.show_choice(app.capture_choices, selected=app.capture_nbr)
+        win.scene.set_selected_choice(app.capture_nbr)
         self.layout_timer.start(cfg.getfloat('WINDOW', 'chosen_delay'))
 
     @pibooth.hookimpl
@@ -120,44 +120,32 @@ def state_chosen_validate(self):
     def state_preview_enter(self, app, win):
         self.count += 1
         self.capture_finished = False
-        win.set_capture_number(self.count, app.capture_nbr)
+        win.scene.set_capture_number(self.count, app.capture_nbr)
 
     @pibooth.hookimpl
     def state_preview_do(self, win, events):
         for event in events:
-            if event.type == camera.EVT_CAMERA_PREVIEW:
-                win.show_image(event.result)
-
-    @pibooth.hookimpl
-    def state_capture_enter(self):
-        self.count_flash = 0
-        self.flash_timer.start()
+            if event.type == evts.EVT_PIBOOTH_CAM_PREVIEW:
+                win.scene.set_image(event.result)
 
     @pibooth.hookimpl
-    def state_capture_do(self, cfg, app, win, events):
+    def state_capture_enter(self, cfg, app, win):
         if cfg.getboolean('WINDOW', 'flash'):
-            if self.flash_timer.is_timeout():
-                self.count_flash += 1
-                win.toggle_flash()
-                self.flash_timer.start()
-
-        win.set_capture_number(self.count, app.capture_nbr)
+            win.scene.trigger_flash()
+        win.scene.set_capture_number(self.count, app.capture_nbr)
 
-        for event in events:
-            if event.type == camera.EVT_CAMERA_CAPTURE:
-                self.capture_finished = True
+    @pibooth.hookimpl
+    def state_capture_do(self, events):
+        if evts.find_event(events, evts.EVT_PIBOOTH_CAM_CAPTURE):
+            self.capture_finished = True
 
     @pibooth.hookimpl
-    def state_capture_validate(self, cfg, app):
-        if self.capture_finished and (not cfg.getboolean('WINDOW', 'flash') or self.count_flash >= 4):
+    def state_capture_validate(self, cfg, app, win):
+        if self.capture_finished and (not cfg.getboolean('WINDOW', 'flash') or win.scene.count_flash >= 3):
             if self.count >= app.capture_nbr:
                 return 'processing'
             return 'preview'
 
-    @pibooth.hookimpl
-    def state_processing_enter(self, win):
-        win.show_work_in_progress()
-
     @pibooth.hookimpl
     def state_processing_validate(self, cfg, app):
         if app.previous_picture:  # Processing is finished
@@ -169,14 +157,14 @@ def state_processing_validate(self, cfg, app):
     @pibooth.hookimpl
     def state_print_enter(self, cfg, app, win):
         LOGGER.info("Display the final picture")
-        win.show_print(app.previous_picture)
+        win.scene.set_image(app.previous_picture)
         win.set_print_number(len(app.printer.get_all_tasks()), not app.printer.is_ready())
         self.print_view_timer.start(cfg.getfloat('PRINTER', 'printer_delay'))
 
     @pibooth.hookimpl
     def state_print_validate(self, app, win, events):
-        printed = app.find_print_event(events)
-        self.forgotten = app.find_capture_event(events)
+        printed = evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT)
+        self.forgotten = evts.find_event(events, evts.EVT_PIBOOTH_BTN_CAPTURE)
         if self.print_view_timer.is_timeout() or printed or self.forgotten:
             if printed:
                 win.set_print_number(len(app.printer.get_all_tasks()), not app.printer.is_ready())
@@ -185,10 +173,10 @@ def state_print_validate(self, app, win, events):
     @pibooth.hookimpl
     def state_finish_enter(self, cfg, app, win):
         if cfg.getfloat('WINDOW', 'finish_picture_delay') > 0 and not self.forgotten:
-            win.show_finished(app.previous_picture)
+            win.scene.set_image(app.previous_picture)
             timeout = cfg.getfloat('WINDOW', 'finish_picture_delay')
         else:
-            win.show_finished()
+            win.scene.set_image(None)
             timeout = 1
 
         self.finish_timer.start(timeout)
diff --git a/pibooth/printer.py b/pibooth/printer.py
index 29443166..d0c5413f 100644
--- a/pibooth/printer.py
+++ b/pibooth/printer.py
@@ -15,11 +15,10 @@
 import pygame
 from PIL import Image
 from pibooth.utils import LOGGER
+from pibooth import evts
 from pibooth.pictures import get_picture_factory
 
 
-EVT_PRINTER_TASKS_UPDATED = pygame.USEREVENT + 2
-
 PAPER_FORMATS = {
     '2x6': (2, 6),      # 2x6 pouces - 5x15 cm - 51x152 mm
     '3,5x5': (3.5, 5),  # 3,5x5 pouces - 9x13 cm - 89x127 mm
@@ -64,13 +63,12 @@ def __init__(self, name='default', max_pages=-1, options=None, counters=None):
         elif not self.options:
             self.options = {}
 
-    def _on_event(self, evt):
+    def _on_event(self, event):
         """
         Call for each new printer event.
         """
-        LOGGER.info(evt.title)
-        pygame.event.post(pygame.event.Event(EVT_PRINTER_TASKS_UPDATED,
-                                             tasks=self.get_all_tasks()))
+        LOGGER.info(event.title)
+        evts.post(evts.EVT_PIBOOTH_PRINTER_UPDATE, notification=event)
 
     def is_installed(self):
         """Return True if the CUPS server is available for printing.
diff --git a/pibooth/scripts/count.py b/pibooth/scripts/count.py
index 8f3f894b..4730ebbd 100644
--- a/pibooth/scripts/count.py
+++ b/pibooth/scripts/count.py
@@ -7,7 +7,7 @@
 import json
 from pibooth.counters import Counters
 from pibooth.utils import configure_logging
-from pibooth.config import PiConfigParser
+from pibooth.config import PiboothConfigParser
 from pibooth.plugins import create_plugin_manager
 
 
@@ -16,7 +16,7 @@ def main():
     """
     configure_logging()
     plugin_manager = create_plugin_manager()
-    config = PiConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
+    config = PiboothConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
 
     counters = Counters(config.join_path("counters.pickle"),
                         taken=0, printed=0, forgotten=0,
@@ -28,7 +28,7 @@ def main():
         try:
             print("\nUpdating counters (current value in square bracket):\n")
             for name in counters:
-                value = input(" -> {:.<18} [{:>4}] : ".format(name.capitalize(), counters[name]))
+                value = input(" -> {:.<20} [{:>4}] : ".format(name.capitalize(), counters[name]))
                 if value.strip():
                     setattr(counters, name, int(value))
         except KeyboardInterrupt:
@@ -37,7 +37,7 @@ def main():
     else:
         print("\nListing current counters:\n")
         for name in counters:
-            print(" -> {:.<25} : {:>4}".format(name.capitalize(), counters[name]))
+            print(" -> {:.<27} : {:>4}".format(name.capitalize(), counters[name]))
         print()
 
 
diff --git a/pibooth/scripts/diagnostic.py b/pibooth/scripts/diagnostic.py
index de09ac13..31ee414d 100755
--- a/pibooth/scripts/diagnostic.py
+++ b/pibooth/scripts/diagnostic.py
@@ -11,7 +11,7 @@
 except ImportError:
     gp = None  # gphoto2 is optional
 import pibooth
-from pibooth.config import PiConfigParser
+from pibooth.config import PiboothConfigParser
 from pibooth.utils import configure_logging
 from pibooth.plugins import create_plugin_manager
 
@@ -131,7 +131,7 @@ def main():
     write_log("Pibooth version installed: {}".format(pibooth.__version__))
 
     plugin_manager = create_plugin_manager()
-    config = PiConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
+    config = PiboothConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
 
     # Register plugins
     plugin_manager.load_all_plugins(config.gettuple('GENERAL', 'plugins', 'path'),
@@ -143,6 +143,14 @@ def main():
     if not gp:
         write_log("gPhoto2 not installed, cannot diagnose connected DSLR")
         sys.exit(1)
+    else:
+        try:
+            info = gp.version.gp_library_version(gp.version.GP_VERSION_VERBOSE)
+            write_log("GPhoto2 version installed: {}".format(info[0]))
+            for opt in info[1:]:
+                write_log("  - {}".format(opt))
+        except:
+            pass
 
     gp_log_callback = gp.check_result(gp.gp_log_add_func(gp.GP_LOG_VERBOSE, gp_logging))
     write_log("Listing all connected DSLR camera")
diff --git a/pibooth/scripts/printer.py b/pibooth/scripts/printer.py
index 16eacdf8..28afc528 100644
--- a/pibooth/scripts/printer.py
+++ b/pibooth/scripts/printer.py
@@ -7,7 +7,7 @@
 import json
 import cups
 from pibooth.utils import LOGGER, configure_logging
-from pibooth.config import PiConfigParser
+from pibooth.config import PiboothConfigParser
 from pibooth.plugins import create_plugin_manager
 
 
@@ -16,7 +16,7 @@ def main():
     """
     configure_logging()
     plugin_manager = create_plugin_manager()
-    config = PiConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
+    config = PiboothConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
 
     conn = cups.Connection()
     name = config.get('PRINTER', 'printer_name')
diff --git a/pibooth/scripts/regenerate.py b/pibooth/scripts/regenerate.py
index 4ebad2cf..9786d131 100644
--- a/pibooth/scripts/regenerate.py
+++ b/pibooth/scripts/regenerate.py
@@ -5,12 +5,14 @@
 
 import os
 from os import path as osp
+from datetime import datetime
 
 from PIL import Image
 
 from pibooth.utils import LOGGER, configure_logging
 from pibooth.plugins import create_plugin_manager
-from pibooth.config import PiConfigParser
+from pibooth.config import PiboothConfigParser
+from pibooth.counters import Counters
 from pibooth.pictures import get_picture_factory
 
 
@@ -34,7 +36,7 @@ def regenerate_all_images(plugin_manager, config, basepath):
     if not osp.isdir(osp.join(basepath, 'raw')):
         return
 
-    capture_choices = config.gettuple('PICTURE', 'captures', int, 2)
+    capture_choices = config.gettuple('PICTURE', 'captures', int)
 
     for captures_folder in os.listdir(osp.join(basepath, 'raw')):
         captures_folder_path = osp.join(basepath, 'raw', captures_folder)
@@ -43,14 +45,11 @@ def regenerate_all_images(plugin_manager, config, basepath):
         captures = get_captures(captures_folder_path)
         LOGGER.info("Generating image from raws in folder %s", captures_folder_path)
 
-        if len(captures) == capture_choices[0]:
-            idx = 0
-        elif len(captures) == capture_choices[1]:
-            idx = 1
-        else:
+        if len(captures) not in capture_choices:
             LOGGER.warning("Folder %s doesn't contain the correct number of pictures", captures_folder_path)
             continue
 
+        idx = capture_choices.index(len(captures))
         default_factory = get_picture_factory(captures, config.get('PICTURE', 'orientation'))
         factory = plugin_manager.hook.pibooth_setup_picture_factory(cfg=config,
                                                                     opt_index=idx,
@@ -65,7 +64,7 @@ def main():
     """
     configure_logging()
     plugin_manager = create_plugin_manager()
-    config = PiConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
+    config = PiboothConfigParser("~/.config/pibooth/pibooth.cfg", plugin_manager)
 
     # Register plugins
     plugin_manager.load_all_plugins(config.gettuple('GENERAL', 'plugins', 'path'),
@@ -77,6 +76,13 @@ def main():
     # Update configuration with plugins ones
     plugin_manager.hook.pibooth_configure(cfg=config)
 
+    # Initialize text variables (normally done by the app)
+    picture_plugin = plugin_manager.get_plugin('pibooth-core:picture')
+    picture_plugin.texts_vars['date'] = datetime.now()
+    picture_plugin.texts_vars['count'] = Counters(config.join_path("counters.pickle"),
+                                                  taken=0, printed=0, forgotten=0,
+                                                  remaining_duplicates=config.getint('PRINTER', 'max_duplicates'))
+
     for path in config.gettuple('GENERAL', 'directory', 'path'):
         regenerate_all_images(plugin_manager, config, path)
 
diff --git a/pibooth/states.py b/pibooth/states.py
index 26fe8675..55a71735 100644
--- a/pibooth/states.py
+++ b/pibooth/states.py
@@ -13,12 +13,12 @@ def __init__(self, plugins_manager, configuration, application, window):
         self.states = set()
         self.failsafe_state = None
         self.active_state = None
+        self.pm = plugins_manager
 
-        # Share the application to manage between states
+        # Shared variables between states
         self.app = application
         self.win = window
         self.cfg = configuration
-        self.pm = plugins_manager
 
         self._start_time = time.time()
 
@@ -26,17 +26,20 @@ def add_state(self, name):
         """Add a state to the internal dictionary.
         """
         self.states.add(name)
+        self.win.add_scene(name)
 
     def add_failsafe_state(self, name):
         """Add a state that will be call in case of exception.
         """
         self.failsafe_state = name
         self.states.add(name)
+        self.win.add_scene(name)
 
     def remove_state(self, name):
         """Remove a state to the internal dictionary.
         """
         self.states.discard(name)
+        self.win.remove_scene(name)
         if name == self.failsafe_state:
             self.failsafe_state = None
 
@@ -94,6 +97,7 @@ def set_state(self, state_name):
         self.active_state = state_name
 
         try:
+            self.win.set_scene(self.active_state)
             hook = getattr(self.pm.hook, 'state_{}_enter'.format(self.active_state))
             hook(cfg=self.cfg, app=self.app, win=self.win)
         except Exception as ex:
diff --git a/pibooth/tasks.py b/pibooth/tasks.py
new file mode 100644
index 00000000..414e0677
--- /dev/null
+++ b/pibooth/tasks.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+
+"""Pibooth asynchron tasks.
+"""
+
+import threading
+from concurrent import futures
+
+from pibooth import evts
+
+
+class AsyncTasksPool(object):
+
+    """Class to manage a pool of asynchronous tasks.
+    """
+
+    FUTURES = {}
+
+    def __init__(self):
+        if not AsyncTask.POOL:
+            self._pool = futures.ThreadPoolExecutor()
+            AsyncTask.POOL = self
+        else:
+            self._pool = AsyncTask.POOL._pool
+
+    def start_task(self, task):
+        "Start an asynchronous task and add future on tracking list."
+        assert isinstance(task, AsyncTask)
+        future = self._pool.submit(task)
+        self.FUTURES[future] = task
+        future.add_done_callback(self.finish_task)
+        return future
+
+    def finish_task(self, future):
+        """Remove future from tracking list.
+        """
+        self.FUTURES.pop(future)
+        future.result()  # Re-raise exception if it has occured during run
+
+    def quit(self):
+        """Stop all tasks and don't accept new one.
+        """
+        for task in self.FUTURES.values():
+            task.kill()
+        self.FUTURES.clear()
+        self._pool.shutdown(wait=True)
+
+
+class AsyncTask(object):
+
+    """Class to execute an asynchronous task.
+
+    :param runnable: a callable object to execute asynchronously:
+    :type runnable: callable
+    :param args: arguments to pass to the runnable
+    :type args: tuple, list
+    :param event: a event to post (with 'result') at task end
+    :type event: int
+    :param loop: execute the runnable in an infinit loop if True
+    :type loop: bool
+    """
+
+    POOL = None
+
+    def __init__(self, runnable, args=(), event=None, loop=False):
+        if not self.POOL:
+            raise EnvironmentError("Tasks pool is not initialized.")
+        self._stop_event = threading.Event()
+        self.runnable = runnable
+        self.args = args
+        self.event_type = event
+        self.loop = loop
+        self.future = self.POOL.start_task(self)
+
+    def __call__(self):
+        """Execute the runnable.
+        """
+        result = None
+        while not self._stop_event.is_set():
+            result = self.runnable(*self.args)
+            self.emit(result)
+            if not self.loop:
+                break
+        return result
+
+    def emit(self, data):
+        """Post event with the result of the task.
+        """
+        if self.event_type is not None:
+            evts.post(self.event_type, result=data)
+
+    def result(self):
+        """Return task result or None if task is not finished.
+        """
+        try:
+            return self.future.result(timeout=0.01)
+        except futures.TimeoutError:
+            return None
+
+    def is_alive(self):
+        """Return true if the task is not yet started or running.
+        """
+        return not self.future.done()
+
+    def wait(self, timeout=None):
+        """Wait for task ends or cancelled and return the result.
+        """
+        try:
+            return self.future.result(timeout)
+        except futures.TimeoutError:
+            raise
+        except Exception:
+            return None
+
+    def kill(self):
+        """Stop running.
+        """
+        self._stop_event.set()
+        self.future.cancel()
+        self.wait(30)  # Max 30 seconds
diff --git a/pibooth/utils.py b/pibooth/utils.py
index b639891b..89f941f4 100644
--- a/pibooth/utils.py
+++ b/pibooth/utils.py
@@ -8,101 +8,22 @@
 import time
 import os.path as osp
 import logging
-import threading
 import psutil
 import platform
 from fnmatch import fnmatchcase
 import contextlib
 import errno
 import subprocess
-from concurrent import futures
-import pygame
-
 
 LOGGER = logging.getLogger("pibooth")
 
 
-class AsyncTask(object):
-
-    POOL = futures.ThreadPoolExecutor()
-    FUTURES = {}
-
-    def __init__(self, runnable, args=(), event=None, loop=False):
-        self._stop_event = threading.Event()
-        self.runnable = runnable
-        self.event_type = event
-        self.loop = loop
-        self.future = self.POOL.submit(self._run, *args)
-        self.FUTURES[self.future] = self
-        self.future.add_done_callback(self._finish)
-
-    def _finish(self, future):
-        """Remove future from tracking list.
-        """
-        self.FUTURES.pop(future)
-        self.future.result()  # Raise exception is occures during run
-
-    def _run(self, *args, **kwargs):
-        """Execute the runnable.
-        """
-        result = None
-        while not self._stop_event.is_set():
-            result = self.runnable(*args, **kwargs)
-            self.emit(result)
-            if not self.loop:
-                break
-        return result
-
-    def emit(self, data):
-        """Post event with the result of the task.
-        """
-        if self.event_type is not None:
-            event = pygame.event.Event(self.event_type, result=data)
-            pygame.event.post(event)
-
-    def result(self):
-        """Return task result.
-        """
-        return self.future.result()
-
-    def is_alive(self):
-        """Return true if the task is not yet started or running.
-        """
-        return not self.future.done()
-
-    def wait(self, timeout=None):
-        """Wait for task ends or cancelled.
-        """
-        try:
-            return self.future.result(timeout)
-        except futures.TimeoutError:
-            raise
-        except Exception:
-            return None
-
-    def kill(self):
-        """Stop running.
-        """
-        self._stop_event.set()
-        self.future.cancel()
-        self.wait()
-
-    @classmethod
-    def kill_all(cls):
-        """Stop all tasks and don't accept new one.
-        """
-        for task in cls.FUTURES.values():
-            task.kill()
-        cls.FUTURES.clear()
-        cls.POOL.shutdown(wait=True)
-
-
 class BlockConsoleHandler(logging.StreamHandler):
 
     default_level = logging.INFO
-    pattern_indent = '+< '
-    pattern_blocks = '|  '
-    pattern_dedent = '+> '
+    pattern_start = '+<- '
+    pattern_block = '|   '
+    pattern_end = '+-> '
     current_indent = ''
 
     def emit(self, record):
@@ -111,10 +32,10 @@ def emit(self, record):
             record.msg = '{}{}'.format(cls.current_indent, record.msg)
         logging.StreamHandler.emit(self, record)
 
-        if cls.current_indent.endswith(cls.pattern_indent):
-            cls.current_indent = (cls.current_indent[:-len(cls.pattern_indent)] + cls.pattern_blocks)
-        elif cls.current_indent.endswith(cls.pattern_dedent):
-            cls.current_indent = cls.current_indent[:-len(cls.pattern_dedent)]
+        if cls.current_indent.endswith(cls.pattern_start):
+            cls.current_indent = (cls.current_indent[:-len(cls.pattern_start)] + cls.pattern_block)
+        elif cls.current_indent.endswith(cls.pattern_end):
+            cls.current_indent = cls.current_indent[:-len(cls.pattern_end)]
 
     @classmethod
     def is_debug(cls):
@@ -130,14 +51,14 @@ def indent(cls):
         """Begin a new log block.
         """
         if cls.is_debug():
-            cls.current_indent += cls.pattern_indent
+            cls.current_indent += cls.pattern_start
 
     @classmethod
     def dedent(cls):
         """End the current log block.
         """
         if cls.is_debug():
-            cls.current_indent = (cls.current_indent[:-len(cls.pattern_blocks)] + cls.pattern_dedent)
+            cls.current_indent = (cls.current_indent[:-len(cls.pattern_block)] + cls.pattern_end)
 
 
 class PollingTimer(object):
@@ -187,6 +108,11 @@ def start(self, timeout=None):
             self._paused_total = 0
             self.time = time.time()
 
+    def is_started(self):
+        """Return True if time is started.
+        """
+        return self.time is not None
+
     def freeze(self):
         """Pause the timer.
         """
@@ -281,7 +207,7 @@ def get_logging_filename():
 def get_crash_message():
     msg = "system='{}', node='{}', release='{}', version='{}', machine='{}', processor='{}'\n".format(*platform.uname())
     msg += " " + "*" * 83 + "\n"
-    msg += " * " + "Oops! It seems that pibooth has crached".center(80) + "*\n"
+    msg += " * " + "Oops! It seems that pibooth has crashed".center(80) + "*\n"
     msg += " * " + "You can report an issue on https://github.com/pibooth/pibooth/issues/new".center(80) + "*\n"
     if get_logging_filename():
         msg += " * " + ("and post the file: {}".format(get_logging_filename())).center(80) + "*\n"
@@ -360,17 +286,3 @@ def load_module(path):
                 return spec.loader.load_module(modname)
 
     LOGGER.warning("Can not load Python module '%s' from '%s'", modname, path)
-
-
-def get_event_pos(display_size, event):
-    """
-    Return the position from finger or mouse event on x-axis and y-axis (x, y).
-
-    :param display_size: size of display for relative positioning in finger events
-    :param event: pygame event object
-    :return: position (x, y) in px
-    """
-    if event.type in (pygame.FINGERDOWN, pygame.FINGERMOTION, pygame.FINGERUP):
-        finger_pos = (event.x * display_size[0], event.y * display_size[1])
-        return finger_pos
-    return event.pos
diff --git a/pibooth/view/__init__.py b/pibooth/view/__init__.py
index 6a637c5b..0577e58c 100644
--- a/pibooth/view/__init__.py
+++ b/pibooth/view/__init__.py
@@ -1,4 +1,37 @@
 # -*- coding: utf-8 -*-
 
-from pibooth.view.window import PiWindow
-from pibooth.view.menu import PiConfigMenu
+from pibooth.view.pygame.window import PygameWindow
+from pibooth.view.nogui.window import NoGuiWindow
+
+WINDOWS = {
+    'nogui': NoGuiWindow,
+    'pygame': PygameWindow
+}
+
+
+def get_window(window_type, title, size, background, text_color, debug=False):
+    """Return the window according to the given type.
+
+    :param window_type: type of the GUI used
+    :type window_type: str
+    :param title: title to display on the window
+    :type title: str
+    :param size: (width, height) to define the window size
+    :type size: tuple
+    :param background: background image path of RGB color tuple
+    :type background: tuple or str
+    :param text_color: text RGB color tuple
+    :type text_color: tuple
+    :param debug: display red indictions for debugging purpose
+    :type debug: bool
+    """
+    if window_type not in WINDOWS:
+        raise ValueError(f"Unknown window type '{window_type}'")
+
+    window_class = WINDOWS[window_type]
+
+    if isinstance(size, (tuple, list)):
+        return window_class(title, size, background=background, text_color=text_color, debug=debug)
+    else:
+        assert size == 'fullscreen', f"Invalid window size '{size}'"
+        return window_class(title, background=background, text_color=text_color, debug=debug)
diff --git a/pibooth/view/background.py b/pibooth/view/background.py
deleted file mode 100644
index 2ab9f5c8..00000000
--- a/pibooth/view/background.py
+++ /dev/null
@@ -1,675 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import os.path as osp
-import pygame
-
-from pibooth import fonts, pictures
-from pibooth.language import get_translated_text
-
-ARROW_TOP = 'top'
-ARROW_BOTTOM = 'bottom'
-ARROW_HIDDEN = 'hidden'
-ARROW_TOUCH = 'touchscreen'
-
-
-def multiline_text_to_surfaces(text, color, rect, align='center'):
-    """Return a list of surfaces corresponding to each line of the text.
-    The surfaces are next to each others in order to fit the given rect.
-
-    The ``align`` parameter can be one of:
-       * top-left
-       * top-center
-       * top-right
-       * center-left
-       * center
-       * center-right
-       * bottom-left
-       * bottom-center
-       * bottom-right
-    """
-    surfaces = []
-    lines = text.splitlines()
-
-    font = fonts.get_pygame_font(max(lines, key=len), fonts.CURRENT,
-                                 rect.width, rect.height / len(lines))
-    for i, line in enumerate(lines):
-        surface = font.render(line, True, color)
-
-        if align.endswith('left'):
-            x = rect.left
-        elif align.endswith('center'):
-            x = rect.centerx - surface.get_rect().width / 2
-        elif align.endswith('right'):
-            x = rect.right - surface.get_rect().width / 2
-        else:
-            raise ValueError("Invalid horizontal alignment '{}'".format(align))
-
-        height = surface.get_rect().height
-        if align.startswith('top'):
-            y = rect.top + i * height
-        elif align.startswith('center'):
-            y = rect.centery - len(lines) * height / 2 + i * height
-        elif align.startswith('bottom'):
-            y = rect.bottom - (len(lines) - i) * height
-        else:
-            raise ValueError("Invalid vertical alignment '{}'".format(align))
-
-        surfaces.append((surface, surface.get_rect(x=x, y=y)))
-    return surfaces
-
-
-class Background(object):
-
-    def __init__(self, image_name, color=(0, 0, 0), text_color=(255, 255, 255)):
-        self._rect = None
-        self._name = image_name
-        self._need_update = False
-
-        self._background = None
-        self._background_color = color
-        self._background_image = None
-
-        self._overlay = None
-
-        self._texts = []  # List of (surface, rect)
-        self._text_border = 20  # Distance to other elements
-        self._text_color = text_color
-
-        # Build rectangles around some areas for debuging purpose
-        self._show_outlines = True
-        self._outlines = []
-
-    def __str__(self):
-        """Return background final name.
-
-        It is used in the main window to distinguish backgrounds in the cache
-        thus each background string shall be uniq.
-        """
-        return "{}({})".format(self.__class__.__name__, self._name)
-
-    def _make_outlines(self, size):
-        """Return a red rectangle surface.
-        """
-        outlines = pygame.Surface(size, pygame.SRCALPHA, 32)
-        pygame.draw.rect(outlines, pygame.Color(255, 0, 0), outlines.get_rect(), 2)
-        return outlines
-
-    def _write_text(self, text, rect=None, align='center'):
-        """Write a text in the given rectangle.
-        """
-        if not rect:
-            rect = self._rect.inflate(-self._text_border, -self._text_border)
-        if self._show_outlines:
-            self._outlines.append((self._make_outlines(rect.size), rect))
-        self._texts.extend(multiline_text_to_surfaces(text, self._text_color, rect, align))
-
-    def set_color(self, color_or_path):
-        """Set background color (RGB tuple) or path to an image that used to
-        fill the background.
-
-        :param color_or_path: RGB color tuple or image path
-        :type color_or_path: tuple or str
-        """
-        if isinstance(color_or_path, (tuple, list)):
-            assert len(color_or_path) == 3, "Length of 3 is required for RGB tuple"
-            if color_or_path != self._background_color:
-                self._background_color = color_or_path
-                self._need_update = True
-        else:
-            assert osp.isfile(color_or_path), "Invalid image for window background: '{}'".format(color_or_path)
-            if color_or_path != self._background_image:
-                self._background_image = color_or_path
-                self._need_update = True
-
-    def set_text_color(self, color):
-        """Set text color (RGB tuple) used to write the texts.
-
-        :param color: RGB color tuple
-        :type color: tuple
-        """
-        assert len(color) == 3, "Length of 3 is required for RGB tuple"
-        if color != self._text_color:
-            self._text_color = color
-            self._need_update = True
-
-    def set_outlines(self, outlines=True):
-        """Draw outlines for each rectangle available for drawing
-        texts.
-
-        :param outlines: enable / disable outlines
-        :type outlines: bool
-        """
-        if outlines != self._show_outlines:
-            self._show_outlines = outlines
-            self._need_update = True
-
-    def resize(self, screen):
-        """Resize objects to fit to the screen.
-        """
-        if self._rect != screen.get_rect():
-            self._rect = screen.get_rect()
-            self._outlines = []
-
-            if self._background_image:
-                self._background = pictures.get_pygame_image(
-                    self._background_image, (self._rect.width, self._rect.height), crop=True, color=None)
-                self._background_color = pictures.get_pygame_main_color(self._background)
-
-            overlay_name = "{}.png".format(self._name)
-            if osp.isfile(pictures.get_filename(overlay_name)):
-                self._overlay = pictures.get_pygame_image(
-                    pictures.get_filename(overlay_name), (self._rect.width, self._rect.height), color=self._text_color, bg_color=self._background_color)
-
-            self.resize_texts()
-            self._need_update = True
-
-    def resize_texts(self, rect=None, align='center'):
-        """Update text surfaces.
-        """
-        self._texts = []
-        text = get_translated_text(self._name)
-        if text:
-            self._write_text(text, rect, align)
-
-    def paint(self, screen):
-        """Paint and animate the surfaces on the screen.
-        """
-        if self._background:
-            screen.blit(self._background, (0, 0))
-        else:
-            screen.fill(self._background_color)
-        if self._overlay:
-            screen.blit(self._overlay, self._overlay.get_rect(center=self._rect.center))
-        for text_surface, pos in self._texts:
-            screen.blit(text_surface, pos)
-        for outline_surface, pos in self._outlines:
-            screen.blit(outline_surface, pos)
-        self._need_update = False
-
-
-class IntroBackground(Background):
-
-    def __init__(self, arrow_location=ARROW_BOTTOM, arrow_offset=0):
-        Background.__init__(self, "intro")
-        self.arrow_location = arrow_location
-        self.arrow_offset = arrow_offset
-        self.left_arrow = None
-        self.left_arrow_pos = None
-
-    def resize(self, screen):
-        Background.resize(self, screen)
-        if self._need_update and self.arrow_location != ARROW_HIDDEN:
-            if self.arrow_location == ARROW_TOUCH:
-                size = (self._rect.width * 0.2, self._rect.height * 0.2)
-
-                self.left_arrow = pictures.get_pygame_image("camera.png", size, vflip=False, color=self._text_color)
-
-                x = int(self._rect.width * 0.2)
-                y = int(self._rect.height // 2)
-            else:
-                size = (self._rect.width * 0.3, self._rect.height * 0.3)
-
-                vflip = True if self.arrow_location == ARROW_TOP else False
-                self.left_arrow = pictures.get_pygame_image("arrow.png", size, vflip=vflip, color=self._text_color)
-
-                x = int(self._rect.left + self._rect.width // 4
-                        - self.left_arrow.get_rect().width // 2)
-                if self.arrow_location == ARROW_TOP:
-                    y = self._rect.top + 10
-                else:
-                    y = int(self._rect.top + 2 * self._rect.height // 3)
-
-            self.left_arrow_pos = (x - self.arrow_offset, y)
-
-    def resize_texts(self):
-        """Update text surfaces.
-        """
-        if self.arrow_location == ARROW_HIDDEN:
-            rect = pygame.Rect(self._text_border, self._text_border,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height - 2 * self._text_border)
-            align = 'center'
-        elif self.arrow_location == ARROW_BOTTOM:
-            rect = pygame.Rect(self._text_border, self._text_border,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height * 0.6 - self._text_border)
-            align = 'bottom-center'
-        elif self.arrow_location == ARROW_TOUCH:
-            rect = pygame.Rect(self._text_border, self._text_border,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height * 0.4 - self._text_border)
-            align = 'bottom-center'
-        else:
-            rect = pygame.Rect(self._text_border, self._rect.height * 0.4,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height * 0.6 - self._text_border)
-            align = 'top-center'
-        Background.resize_texts(self, rect, align)
-
-    def paint(self, screen):
-        Background.paint(self, screen)
-        if self.arrow_location != ARROW_HIDDEN:
-            screen.blit(self.left_arrow, self.left_arrow_pos)
-
-
-class IntroWithPrintBackground(IntroBackground):
-
-    def __init__(self, arrow_location=ARROW_BOTTOM, arrow_offset=0):
-        IntroBackground.__init__(self, arrow_location, arrow_offset)
-        self.right_arrow = None
-        self.right_arrow_pos = None
-
-    def __str__(self):
-        """Return background final name.
-
-        It is used in the main window to distinguish backgrounds in the cache
-        thus each background string shall be uniq.
-        """
-        return "{}({})".format(self.__class__.__name__, "intro_print")
-
-    def resize(self, screen):
-        IntroBackground.resize(self, screen)
-        if self._need_update and self.arrow_location != ARROW_HIDDEN:
-            size = (self._rect.width * 0.1, self._rect.height * 0.1)
-            if self.arrow_location == ARROW_TOUCH:
-                self.right_arrow = pictures.get_pygame_image("hand.png", size, hflip=False,
-                                                             vflip=False, angle=-70, color=self._text_color)
-            else:
-                vflip = True if self.arrow_location == ARROW_TOP else False
-                angle = -70 if self.arrow_location == ARROW_TOP else 70
-                self.right_arrow = pictures.get_pygame_image("arrow.png", size, hflip=False,
-                                                             vflip=vflip, angle=angle, color=self._text_color)
-
-            x = int(self._rect.left + self._rect.width // 2
-                    - self.right_arrow.get_rect().width // 2)
-            if self.arrow_location == ARROW_TOP:
-                y = self._rect.top + 10
-            else:
-                y = int(self._rect.bottom - self.right_arrow.get_rect().height * 1.1)
-            self.right_arrow_pos = (x - self.arrow_offset, y)
-
-    def resize_texts(self):
-        """Update text surfaces.
-        """
-        IntroBackground.resize_texts(self)
-        text = get_translated_text("intro_print")
-        if text:
-            rect = pygame.Rect(self._rect.width * 0.30 + self._text_border, 0,
-                               self._rect.width * 0.20 - 2 * self._text_border,
-                               self._rect.height * 0.3 - 2 * self._text_border)
-            if self.arrow_location == ARROW_TOP:
-                rect.top = self._rect.height * 0.08
-            else:
-                rect.bottom = self._rect.height - self._rect.height * 0.08
-            self._write_text(text, rect)
-
-    def paint(self, screen):
-        IntroBackground.paint(self, screen)
-        if self.arrow_location != ARROW_HIDDEN:
-            screen.blit(self.right_arrow, self.right_arrow_pos)
-
-
-class ChooseBackground(Background):
-
-    def __init__(self, choices, arrow_location=ARROW_BOTTOM, arrow_offset=0):
-        Background.__init__(self, "choose")
-        self.arrow_location = arrow_location
-        self.arrow_offset = arrow_offset
-        self.choices = choices
-        self.layout0 = None
-        self.layout0_pos = None
-        self.layout1 = None
-        self.layout1_pos = None
-        self.left_arrow = None
-        self.left_arrow_pos = None
-        self.right_arrow = None
-        self.right_arrow_pos = None
-
-    def resize(self, screen):
-        Background.resize(self, screen)
-        if self._need_update:
-            size = (self._rect.width * 0.45, self._rect.height * 0.6)
-            self.layout0 = pictures.get_pygame_layout_image(
-                self._text_color, self._background_color, self.choices[0], size)
-            self.layout1 = pictures.get_pygame_layout_image(
-                self._text_color, self._background_color, self.choices[1], size)
-
-            inter = (self._rect.width - 2 * self.layout0.get_rect().width) // 3
-
-            x0 = int(self._rect.left + inter)
-            x1 = int(self._rect.left + 2 * inter + self.layout0.get_rect().width)
-            y = int(self._rect.top + self._rect.height * 0.3)
-
-            self.layout0_pos = (x0, y)
-            self.layout1_pos = (x1, y)
-
-            if self.arrow_location in [ARROW_TOP, ARROW_BOTTOM]:
-                if self.arrow_location == ARROW_TOP:
-                    y = 5
-                    x_offset = 30
-                    size = (self._rect.width * 0.1, self._rect.top + y + 30)
-                else:
-                    x_offset = 0
-                    y = self.layout0_pos[1] + self.layout0.get_rect().height + 5
-                    size = (self._rect.width * 0.1, self._rect.bottom - y - 5)
-
-                vflip = True if self.arrow_location == ARROW_TOP else False
-                self.left_arrow = pictures.get_pygame_image("arrow.png", size, vflip=vflip,
-                                                            color=self._text_color)
-                self.right_arrow = pictures.get_pygame_image("arrow.png", size, hflip=True,
-                                                             vflip=vflip, color=self._text_color)
-
-                inter = (self._rect.width - 2 * self.left_arrow.get_rect().width) // 4
-
-                x0 = int(self._rect.left + inter) - x_offset
-                x1 = int(self._rect.left + 3 * inter + self.left_arrow.get_rect().width) + x_offset
-
-                self.left_arrow_pos = (x0 - self.arrow_offset, y)
-                self.right_arrow_pos = (x1 + self.arrow_offset, y)
-
-    def resize_texts(self):
-        """Update text surfaces.
-        """
-        rect = pygame.Rect(self._text_border, self._text_border,
-                           self._rect.width - 2 * self._text_border, self._rect.height * 0.2)
-        Background.resize_texts(self, rect)
-
-    def paint(self, screen):
-        Background.paint(self, screen)
-        screen.blit(self.layout0, self.layout0_pos)
-        screen.blit(self.layout1, self.layout1_pos)
-        if self.arrow_location in [ARROW_TOP, ARROW_BOTTOM]:
-            screen.blit(self.left_arrow, self.left_arrow_pos)
-            screen.blit(self.right_arrow, self.right_arrow_pos)
-
-
-class ChosenBackground(Background):
-
-    def __init__(self, choices, selected):
-        Background.__init__(self, "chosen")
-        self.choices = choices
-        self.selected = selected
-        self.layout = None
-        self.layout_pos = None
-
-    def __str__(self):
-        """Return background final name.
-        It is used in the main window to distinguish background in the cache.
-        """
-        return "{}({}{})".format(self.__class__.__name__, self._name, self.selected)
-
-    def resize(self, screen):
-        Background.resize(self, screen)
-        if self._need_update:
-            size = (self._rect.width * 0.6, self._rect.height * 0.6)
-
-            self.layout = pictures.get_pygame_layout_image(
-                self._text_color, self._background_color, self.selected, size)
-
-            x = self.layout.get_rect(center=self._rect.center).left
-            y = int(self._rect.top + self._rect.height * 0.3)
-
-            self.layout_pos = (x, y)
-
-    def resize_texts(self):
-        """Update text surfaces.
-        """
-        rect = pygame.Rect(self._text_border, self._text_border,
-                           self._rect.width - 2 * self._text_border, self._rect.height * 0.2)
-        Background.resize_texts(self, rect)
-
-    def paint(self, screen):
-        Background.paint(self, screen)
-        screen.blit(self.layout, self.layout_pos)
-
-
-class CaptureBackground(Background):
-
-    def __init__(self):
-        Background.__init__(self, "capture")
-        self.left_people = None
-        self.left_people_pos = None
-        self.right_people = None
-        self.right_people_pos = None
-
-    def resize(self, screen):
-        Background.resize(self, screen)
-        if self._need_update:
-            images_height = self._rect.height / 4
-            size = (images_height * 2, images_height)
-
-            self.left_people = pictures.get_pygame_image("capture_left.png", size=size,
-                                                         color=self._text_color)
-            self.right_people = pictures.get_pygame_image("capture_right.png", size=size,
-                                                          color=self._text_color)
-
-            x = int(self._rect.right - size[0])
-            y = int(self._rect.bottom - images_height)
-
-            self.left_people_pos = (0, y)
-            self.right_people_pos = (x + size[0] - 1.5 * self.right_people.get_rect().width, y)
-
-            if self._show_outlines:
-                self._outlines.append((self._make_outlines(size), (0, y)))
-                self._outlines.append((self._make_outlines(size), (x, y)))
-
-    def paint(self, screen):
-        Background.paint(self, screen)
-        screen.blit(self.left_people, self.left_people_pos)
-        screen.blit(self.right_people, self.right_people_pos)
-
-
-class ProcessingBackground(Background):
-
-    def __init__(self):
-        Background.__init__(self, "processing")
-
-    def resize_texts(self):
-        """Update text surfaces.
-        """
-        rect = pygame.Rect(self._text_border, self._rect.height * 0.8 - self._text_border,
-                           self._rect.width - 2 * self._text_border, self._rect.height * 0.2)
-        Background.resize_texts(self, rect)
-
-
-class PrintBackground(Background):
-
-    def __init__(self, arrow_location=ARROW_BOTTOM, arrow_offset=0):
-        Background.__init__(self, "print")
-        self.arrow_location = arrow_location
-        self.arrow_offset = arrow_offset
-        self.right_arrow = None
-        self.right_arrow_pos = None
-        self.left_arrow = None
-        self.left_arrow_pos = None
-
-    def resize(self, screen):
-        Background.resize(self, screen)
-        if self._need_update and self.arrow_location != ARROW_HIDDEN:
-
-            if self.arrow_location == ARROW_TOUCH:
-                size = (self._rect.width // 4, self._rect.height // 4)
-                # Right arrow
-                self.right_arrow = pictures.get_pygame_image(
-                    "printer_touch.png", size, hflip=False, vflip=False, color=self._text_color)
-                x = int(self._rect.left + self._rect.width * 0.70
-                        - self.right_arrow.get_rect().width // 2)
-                y = int(self._rect.top + self._rect.height * 0.45)
-            else:
-                size = (self._rect.width * 0.3, self._rect.height * 0.3)
-
-                vflip = True if self.arrow_location == ARROW_TOP else False
-
-                # Right arrow
-                self.right_arrow = pictures.get_pygame_image(
-                    "arrow.png", size, hflip=True, vflip=vflip, color=self._text_color)
-
-                x = int(self._rect.left + self._rect.width * 0.75
-                        - self.right_arrow.get_rect().width // 2)
-                if self.arrow_location == ARROW_TOP:
-                    y = self._rect.top + 10
-                else:
-                    y = int(self._rect.top + 2 * self._rect.height // 3)
-
-            self.right_arrow_pos = (x + self.arrow_offset, y)
-
-            # Left arrow
-            size = (self._rect.width * 0.1, self._rect.height * 0.1)
-
-            if self.arrow_location == ARROW_TOUCH:
-                self.left_arrow = pictures.get_pygame_image(
-                    "hand.png", size, hflip=False, vflip=False, angle=70, color=self._text_color)
-            else:
-                vflip = True if self.arrow_location == ARROW_TOP else False
-                angle = 70 if self.arrow_location == ARROW_TOP else -70
-                self.left_arrow = pictures.get_pygame_image(
-                    "arrow.png", size, hflip=False, vflip=vflip, angle=angle, color=self._text_color)
-
-            x = int(self._rect.left + self._rect.width // 2
-                    - self.left_arrow.get_rect().width // 2)
-
-            if self.arrow_location == ARROW_TOP:
-                y = self._rect.top + 10
-            else:
-                y = int(self._rect.bottom - self.left_arrow.get_rect().height * 1.1)
-
-            self.left_arrow_pos = (x - self.arrow_offset, y)
-
-    def resize_texts(self):
-        """Update text surfaces.
-        """
-        if self.arrow_location == ARROW_HIDDEN:
-            rect = pygame.Rect(self._rect.width / 2 + self._text_border, self._text_border,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height - 2 * self._text_border)
-            align = 'center'
-        elif self.arrow_location == ARROW_BOTTOM:
-            rect = pygame.Rect(self._rect.width / 2 + self._text_border, self._text_border,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height * 0.6 - self._text_border)
-            align = 'bottom-center'
-        elif self.arrow_location == ARROW_TOUCH:
-            rect = pygame.Rect(self._rect.width / 2 + self._text_border, self._text_border,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height * 0.4 - self._text_border)
-            align = 'bottom-center'
-        else:
-            rect = pygame.Rect(self._rect.width / 2 + self._text_border, self._rect.height * 0.4,
-                               self._rect.width / 2 - 2 * self._text_border,
-                               self._rect.height * 0.6 - self._text_border)
-            align = 'top-center'
-        Background.resize_texts(self, rect, align)
-
-        text = get_translated_text("print_forget")
-        if text:
-            rect = pygame.Rect(self._rect.width // 2, 0,
-                               self._rect.width // 5 - 2 * self._text_border,
-                               self._rect.height * 0.3 - 2 * self._text_border)
-            if self.arrow_location == ARROW_TOP:
-                rect.top = self._rect.height * 0.08
-            else:
-                rect.bottom = self._rect.height - self._rect.height * 0.08
-
-            self._write_text(text, rect)
-
-    def paint(self, screen):
-        Background.paint(self, screen)
-        if self.arrow_location != ARROW_HIDDEN:
-            screen.blit(self.right_arrow, self.right_arrow_pos)
-            screen.blit(self.left_arrow, self.left_arrow_pos)
-
-
-class FinishedBackground(Background):
-
-    def __init__(self):
-        Background.__init__(self, "finished")
-        self.left_people = None
-        self.left_people_pos = None
-        self.right_people = None
-        self.right_people_pos = None
-
-    def resize(self, screen):
-        Background.resize(self, screen)
-        if self._need_update:
-            left_rect = pygame.Rect(10, 0, self._rect.width * 0.4, self._rect.height * 0.5)
-            left_rect.top = self._rect.centery - left_rect.centery
-            right_rect = pygame.Rect(0, 0, self._rect.width * 0.3, self._rect.height * 0.5)
-            right_rect.top = self._rect.centery - right_rect.centery
-            right_rect.right = self._rect.right - 10
-
-            self.left_people = pictures.get_pygame_image("finished_left.png", size=left_rect.size,
-                                                         color=self._text_color)
-            self.right_people = pictures.get_pygame_image("finished_right.png", size=right_rect.size,
-                                                          color=self._text_color)
-
-            self.left_people_pos = self.left_people.get_rect(center=left_rect.center).topleft
-            self.right_people_pos = self.right_people.get_rect(center=right_rect.center).topleft
-
-            if self._show_outlines:
-                self._outlines.append((self._make_outlines(left_rect.size), left_rect.topleft))
-                self._outlines.append((self._make_outlines(right_rect.size), right_rect.topleft))
-
-    def resize_texts(self):
-        """Update text surfaces.
-        """
-        rect = pygame.Rect(0, 0, self._rect.width * 0.35, self._rect.height * 0.4)
-        rect.center = self._rect.center
-        rect.bottom = self._rect.bottom - 10
-        Background.resize_texts(self, rect)
-
-    def paint(self, screen):
-        Background.paint(self, screen)
-        if self.left_people:
-            screen.blit(self.left_people, self.left_people_pos)
-        if self.right_people:
-            screen.blit(self.right_people, self.right_people_pos)
-
-
-class FinishedWithImageBackground(FinishedBackground):
-
-    def __init__(self, foreground_size):
-        FinishedBackground.__init__(self)
-        self._name = "finishedwithimage"
-        self.foreground_size = foreground_size
-
-    def resize(self, screen):
-        Background.resize(self, screen)
-        if self._need_update:
-            # Note: '0.9' ratio comes from PiWindow._update_foreground() method which
-            # lets a margin between window borders and fullscreen foreground picture
-            frgnd_rect = pygame.Rect(0, 0, *pictures.sizing.new_size_keep_aspect_ratio(
-                self.foreground_size, (self._rect.size[0] * 0.9, self._rect.size[1]*0.9)))
-            xmargin = abs(self._rect.width - frgnd_rect.width) // 2
-            ymargin = abs(self._rect.height - frgnd_rect.height) // 2
-
-            if xmargin > 50:
-                margin = min(xmargin, self._rect.height // 3)
-            elif ymargin > 50:
-                margin = min(ymargin, self._rect.width // 3)
-            else: # Too small
-                self.left_people = None
-                self.right_people = None
-                return
-
-            left_rect = pygame.Rect(0, 0, margin, margin)
-            right_rect = pygame.Rect(0, 0, margin, margin)
-            left_rect.bottom = self._rect.bottom
-            right_rect.right = self._rect.right
-
-            self.left_people = pictures.get_pygame_image("finished_left.png", size=left_rect.size,
-                                                         color=self._text_color)
-            self.right_people = pictures.get_pygame_image("finished_right.png", size=right_rect.size,
-                                                          color=self._text_color)
-
-            self.left_people_pos = self.left_people.get_rect(center=left_rect.center).topleft
-            self.right_people_pos = self.right_people.get_rect(center=right_rect.center).topleft
-
-            if self._show_outlines and left_rect and right_rect:
-                self._outlines.append((self._make_outlines(left_rect.size), left_rect.topleft))
-                self._outlines.append((self._make_outlines(right_rect.size), right_rect.topleft))
-
-
-class OopsBackground(Background):
-
-    def __init__(self):
-        Background.__init__(self, "oops")
diff --git a/pibooth/view/base.py b/pibooth/view/base.py
new file mode 100644
index 00000000..16cb3395
--- /dev/null
+++ b/pibooth/view/base.py
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+
+"""Base classes for view management.
+"""
+
+import pygame
+from pibooth.utils import LOGGER
+
+
+class BaseScene(object):
+    """Base class for scene.
+
+    Methodes defined here are abstarct and shall be implemented in child classes.
+    Plugins will use them to set severals parameters depending on the pibooth
+    configuration / user choices / new behaviors.
+    """
+
+    ARROW_TOP = 'top'
+    ARROW_BOTTOM = 'bottom'
+    ARROW_HIDDEN = 'hidden'
+    ARROW_TOUCH = 'touchscreen'
+
+    def __init__(self, name):
+        self.name = name
+
+    def resize(self, size):
+        """Recalculate sizes of elements according to the new given size.
+
+        :param size: new size of the scene
+        :type size: tuple
+        """
+        raise NotImplementedError
+
+    def set_outlines(self, enable=True):
+        """Draw outlines for each rectangle available for drawing
+        images and texts.
+
+        :param enable: enable / disable outlines
+        :type enable: bool
+        """
+        raise NotImplementedError
+
+    def set_image(self, image=None):
+        """Set an image to the main place or hide it.
+
+        :param image: image to set (path, PIL object or pygame object)
+        :type image: str or object
+        """
+        raise NotImplementedError
+
+    def set_background(self, color_or_path, size):
+        """Set background sprite.
+
+        :param color_or_path: color of path to an imgae
+        :type color_or_path: tuple or str
+        :param size: size tuple (width, height) of the image.
+        :type size: tuple
+        """
+        raise NotImplementedError
+
+    def set_text_color(self, color):
+        """Set text font color.
+
+        :param color: RGB color tuple for the texts
+        :type color: tuple
+        """
+        raise NotImplementedError
+
+    def set_arrows(self, location, offset):
+        """Set arrows attributes.
+
+        :param location: arrow location: ARROW_HIDDEN, ARROW_BOTTOM, ARROW_TOP, ARROW_TOUCH
+        :type location: str
+        :param offset: x offset from current position to screen outer
+        :type offset: int
+        """
+        raise NotImplementedError
+
+    def set_print_number(self, current_nbr=None, failure=False):
+        raise NotImplementedError
+
+
+class BaseWindow(object):
+
+    """Base class for window.
+
+    The following attributes are available for use in plugins:
+
+    :attr is_fullscreen: True if the window is display in full screen
+    :type is_fullscreen: bool
+    """
+
+    FULLSCREEN = 'fullscreen'
+
+    def __init__(self,
+                 size=(800, 480),
+                 background=(0, 0, 0),
+                 text_color=(255, 255, 255),
+                 arrow_location=BaseScene.ARROW_BOTTOM,
+                 arrow_offset=0,
+                 debug=False):
+        self._size = size  # Size of the window when not fullscreen
+
+        self.debug = debug
+        self.text_color = text_color
+        self.bg_color_or_path = background
+        self.arrow_location = arrow_location
+        self.arrow_offset = arrow_offset
+        self.print_number = 0
+        self.print_failure = False
+
+        self.is_fullscreen = False
+        self.is_menu_shown = False
+
+        self.scenes = {}
+        self.scene = None
+
+    def _create_scene(self, name):
+        """Create scene instance."""
+        raise NotImplementedError
+
+    def add_scene(self, name):
+        """Add a scene to the internal dictionary.
+        """
+        self.scenes[name] = self._create_scene(name)
+
+    def remove_scene(self, name):
+        """Remove a scene from the internal dictionary.
+        """
+        self.scenes.pop(name, None)
+
+    def set_scene(self, name):
+        """Set the current scene.
+        """
+        if name not in self.scenes:
+            raise ValueError('"{}" not in registered scenes...'.format(name))
+
+        LOGGER.debug("Set scene '%s'", name)
+        self.scene = self.scenes[name]
+        self.scene.set_outlines(self.debug)
+        self.scene.set_text_color(self.text_color)
+        self.scene.set_arrows(self.arrow_location, self.arrow_offset)
+        self.scene.set_print_number(self.print_number, self.print_failure)
+        self.resize(self._size)  # Force graphical element to be recalculated
+
+    def set_menu(self, app, cfg):
+        """Set the menu.
+        """
+        pass
+
+    def resize(self, size):
+        """Resize the window only if not fullscreen.
+
+        :param size: new size of the scene
+        :type size: tuple
+        """
+        if not self.is_fullscreen:
+            self._size = size  # Size of the window when not fullscreen
+
+        # Call get_rect() to take new computed size if != self._size
+        self.scene.set_background(self.bg_color_or_path, self.get_rect().size)
+        self.scene.resize(self.get_rect().size)
+
+    def get_rect(self, absolute=False):
+        """Return a Rect object (as defined in pygame) for this window.
+
+        :param absolute: absolute position considering the window centered on screen
+        :type absolute: bool
+        """
+        return pygame.Rect(0, 0, self._size[0], self._size[1])
+
+    def eventloop(self, app_update):
+        """Main GUI events loop (blocking).
+
+        :param app_update: function update application state
+        :type app_update: callable
+        """
+        raise NotImplementedError
+
+    # ---------------------------------------------------------------------
+    # Functions applicable to more than one scene
+
+    def set_print_number(self, current_nbr=None, failure=False):
+        """Set the current number of tasks in the printer queue.
+        """
+        assert current_nbr >= 0, "Current number of printed files shall be greater or equal to 0"
+        self.print_number = current_nbr
+        self.print_failure = failure
+        self.scene.set_print_number(self.print_number, self.print_failure)
+
+    def toggle_fullscreen(self):
+        """Set window to full screen or initial size.
+        """
+        self.is_fullscreen = not self.is_fullscreen
+
+    def toggle_menu(self):
+        """Show/hide settings menu.
+        """
+        self.is_menu_shown = not self.is_menu_shown
diff --git a/pibooth/view/nogui/__init__.py b/pibooth/view/nogui/__init__.py
new file mode 100644
index 00000000..40a96afc
--- /dev/null
+++ b/pibooth/view/nogui/__init__.py
@@ -0,0 +1,1 @@
+# -*- coding: utf-8 -*-
diff --git a/pibooth/view/nogui/window.py b/pibooth/view/nogui/window.py
new file mode 100644
index 00000000..15435c85
--- /dev/null
+++ b/pibooth/view/nogui/window.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+
+"""NoGuiWindow class to emulate a dummy window.
+"""
+
+from functools import partial
+import pygame
+
+from pibooth import evts
+from pibooth.utils import LOGGER
+from pibooth.view.base import BaseWindow, BaseScene
+
+
+class NoGuiScene(BaseScene):
+
+    count_flash = 3
+    selection = None
+    choices = []
+
+    def resize(self, size): pass  # Avoid "NotImplemented" error
+
+    def set_outlines(self, enable=True): pass  # Avoid "NotImplemented" error
+
+    def set_image(self, image=None): pass  # Avoid "NotImplemented" error
+
+    def set_background(self, color_or_path, size): pass  # Avoid "NotImplemented" error
+
+    def set_text_color(self, color): pass  # Avoid "NotImplemented" error
+
+    def set_arrows(self, location, offset): pass  # Avoid "NotImplemented" error
+
+    def set_print_number(self, current_nbr=None, failure=False):  # Avoid "NotImplemented" error
+        self.nop('set_print_number', current_nbr, failure)
+
+    def set_choices(self, choices):
+        self.choices = choices
+
+    def next(self):
+        if not self.selection:
+            self.selection = self.choices[1]
+        else:
+            self.selection = self.choices[self.choices.index(self.selection)+1]
+
+    def get_selection(self):
+        if not self.selection:
+            self.selection = self.choices[0]
+        return self.selection
+
+    def nop(self, attr, *args, **kwargs):
+        LOGGER.info("Scene(%s).%s(%s)", self.name, attr,
+                    ','.join([str(v) for v in args] + ["{k}={v}" for k, v in kwargs.items()]))
+
+    def __getattr__(self, attr):
+        return partial(self.nop, attr)
+
+
+class NoGuiWindow(BaseWindow):
+
+    """Class to print view changes in terminal (useful for tests).
+    """
+
+    def __init__(self, title,
+                 size=(800, 480),
+                 background=(0, 0, 0),
+                 text_color=(255, 255, 255),
+                 arrow_location=BaseScene.ARROW_BOTTOM,
+                 arrow_offset=0,
+                 debug=False):
+        super(NoGuiWindow, self).__init__(size, background, text_color, arrow_location, arrow_offset, debug)
+        LOGGER.info("@@@@@ %s @@@@@ : %s", self.__class__.__name__, title)
+
+        pygame.init()  # Necessary to enable pygame event loop
+
+    def _create_scene(self, name):
+        return NoGuiScene(name)
+
+    def eventloop(self, app_update):
+        """Main GUI events loop (blocking).
+        """
+        fps = 25
+        clock = pygame.time.Clock()
+
+        while True:
+            events = list(pygame.event.get())
+
+            # 0. Update application and plugins according to user events,
+            #    it may update view elements internal variables
+            app_update(events)
+
+            # 1. Convert Pygame events to pibooth events (plugins are based on them)
+            for event in events:
+                if event.type == pygame.QUIT:
+                    return
+
+                if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
+                    LOGGER.debug("[ESC] pressed. No menu configured -> exit")
+                    return
+
+                if event.type == pygame.KEYDOWN and event.key == pygame.K_c:
+                    evts.post_button_capture_event()
+
+                if event.type == pygame.KEYDOWN and event.key == pygame.K_p:
+                    evts.post_button_print_event()
+
+            # 2. Ensure the program will never run at more than <fps> frames per second
+            clock.tick(fps)
diff --git a/pibooth/view/pygame/__init__.py b/pibooth/view/pygame/__init__.py
new file mode 100644
index 00000000..40a96afc
--- /dev/null
+++ b/pibooth/view/pygame/__init__.py
@@ -0,0 +1,1 @@
+# -*- coding: utf-8 -*-
diff --git a/pibooth/view/menu.py b/pibooth/view/pygame/menu.py
similarity index 68%
rename from pibooth/view/menu.py
rename to pibooth/view/pygame/menu.py
index 26500422..b0a3f370 100644
--- a/pibooth/view/menu.py
+++ b/pibooth/view/pygame/menu.py
@@ -5,10 +5,9 @@
 
 import pygame
 import pygame_menu as pgm
-import pygame_vkeyboard as vkb
 import pibooth
 from pibooth import fonts
-from pibooth.utils import LOGGER, get_event_pos
+from pibooth.utils import LOGGER
 from pibooth.config.default import DEFAULT
 
 
@@ -81,40 +80,27 @@ def _counters(counters):
     return [pattern.format(name.replace("_", " ").capitalize(), counters[name]) for name in counters]
 
 
-class PiConfigMenu(object):
+class PygameMenu(object):
 
-    def __init__(self, plugins_manager, configuration, application, window, onclose=None):
+    def __init__(self, application, configuration, callback=None):
         self.app = application
-        self.win = window
         self.cfg = configuration
-        self.pm = plugins_manager
+        self.callback = callback
+        self.size = (600, 400)
         self._changed = False
-        self._close_callback = onclose
-
-        size = self.win.get_rect().size
-        self.size = (min(600, size[0]), min(400, size[1]))
         self._main_menu = pgm.Menu(title="Settings v{}".format(pibooth.__version__),
                                    width=self.size[0],
                                    height=self.size[1],
                                    theme=THEME_DARK,
                                    touchscreen=True,
-                                   onclose=self._on_close)
-        self._main_menu.disable()
+                                   onclose=self.on_close)
         self._main_menu.add.vertical_margin(20)
 
-        self._keyboard = vkb.VKeyboard(self.win.surface,
-                                       self._on_keyboard_event,
-                                       vkb.VKeyboardLayout(vkb.VKeyboardLayout.QWERTY),
-                                       renderer=vkb.VKeyboardRenderer.DARK,
-                                       show_text=True,
-                                       joystick_navigation=True)
-        self._keyboard.disable()
-
         for name in DEFAULT:
             submenu = self._build_submenu(name)
             if len(submenu._widgets) > 2:
                 self._main_menu.add.button(submenu.get_title(), submenu)
-        self._main_menu.add.button('Exit', self._on_exit)
+        self._main_menu.add.button('Exit', self.on_exit)
         self._main_menu.add.vertical_margin(20)
 
     def _build_submenu(self, section):
@@ -137,7 +123,7 @@ def _build_submenu(self, section):
                 title = pattern.format(option[2])
                 if isinstance(option[3], str):
                     menu.add.text_input(title,
-                                        onchange=self._on_text_changed,
+                                        onchange=self.on_text_changed,
                                         default=self.cfg.get(section, name).strip('"'),
                                         # Parameters passed to callback:
                                         section=section,
@@ -148,7 +134,7 @@ def _build_submenu(self, section):
                                          "rgb",
                                          default=self.cfg.gettyped(section, name),
                                          input_separator=',',
-                                         onchange=self._on_color_changed,
+                                         onchange=self.on_color_changed,
                                          previsualization_width=1,
                                          # Parameters passed to callback:
                                          section=section,
@@ -157,7 +143,7 @@ def _build_submenu(self, section):
                     values = [(v,) for v in option[3]]
                     menu.add.selector(title,
                                       values,
-                                      onchange=self._on_selector_changed,
+                                      onchange=self.on_selector_changed,
                                       default=_find(values, self.cfg.get(section, name)),
                                       # Parameters passed to callback:
                                       section=section,
@@ -187,7 +173,7 @@ def _build_submenu_counters(self, title):
         for text in _counters(self.app.count):
             labels.append(menu.add.label(text))
         menu.add.vertical_margin(40)
-        menu.add.button("Reset all", self._on_counters_reset, labels)
+        menu.add.button("Reset all", self.on_counters_reset, labels)
         return menu
 
     def _build_submenu_plugins(self, title):
@@ -206,14 +192,14 @@ def _build_submenu_plugins(self, title):
             menu.add.toggle_switch(pattern.format(self.pm.get_friendly_name(plugin)),
                                    enabled,
                                    state_color=((178, 178, 178), SUBTHEME2_DARK.title_background_color),
-                                   onchange=self._on_plugin_toggled,
+                                   onchange=self.on_plugin_toggled,
                                    # Parameters passed to callback:
                                    section='GENERAL',
                                    option='plugins_disabled',
                                    plugin=plugin)
         return menu
 
-    def _on_keyboard_event(self, text):
+    def on_keyboard_event(self, text):
         """Called after each option changed.
         """
         if self._main_menu.is_enabled():  # Menu may have been closed
@@ -228,35 +214,35 @@ def _on_keyboard_event(self, text):
                     selected.set_value(text)
                 selected.change()
 
-    def _on_selector_changed(self, value, **kwargs):
+    def on_selector_changed(self, value, **kwargs):
         """Called after each option changed.
         """
         if self._main_menu.is_enabled():  # Menu may have been closed
             self.cfg.set(kwargs['section'], kwargs['option'], str(value[0][0]))
             self._changed = True
 
-    def _on_text_changed(self, value, **kwargs):
+    def on_text_changed(self, value, **kwargs):
         """Called after each text input changed.
         """
         if self._main_menu.is_enabled():  # Menu may have been closed
             self.cfg.set(kwargs['section'], kwargs['option'], '"{}"'.format(str(value)))
             self._changed = True
 
-    def _on_color_changed(self, value, **kwargs):
+    def on_color_changed(self, value, **kwargs):
         """Called after each text input changed.
         """
         if self._main_menu.is_enabled():  # Menu may have been closed
             self.cfg.set(kwargs['section'], kwargs['option'], str(value))
             self._changed = True
 
-    def _on_counters_reset(self, labels):
+    def on_counters_reset(self, labels):
         """Called when the counters are reset.
         """
         self.app.count.reset()
         for label, text in zip(labels, _counters(self.app.count)):
             label.set_title(text)
 
-    def _on_plugin_toggled(self, activated, **kwargs):
+    def on_plugin_toggled(self, activated, **kwargs):
         """Called when a plugin active state is toggled.
         """
         plugin = kwargs['plugin']
@@ -287,91 +273,54 @@ def _on_plugin_toggled(self, activated, **kwargs):
             disabled = ''
         self.cfg.set(kwargs['section'], kwargs['option'], str(disabled))
 
-    def _on_close(self):
+    def on_close(self):
         """Called when the menu is closed.
         """
         self._main_menu.disable()
         if self._changed:
             self.cfg.save()
             self._changed = False
-        if self._close_callback:
-            self._close_callback()
 
-    def _on_exit(self):
+    def on_exit(self):
         """Called when the application is exited by menu.
         """
-        self._on_close()
+        self.on_close()
         exit(0)
 
-    def show(self):
+    def enable(self):
         """Show the menu.
         """
         self._main_menu.enable()
 
-    def is_shown(self):
-        """Return True if the menu is shown.
+    def disable(self):
+        """Show the menu.
         """
-        return self._main_menu.is_enabled()
+        self._main_menu.disable()
 
-    def create_click_event(self):
-        """Create a pygame event to click on the currently selected
-        widget on the menu. If the widget is a button, ENTER event
-        is created, else LEFT event is created.
-        """
-        if isinstance(self._main_menu.get_current().get_selected_widget(), pgm.widgets.Button):
-            event = pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_APPLY,
-                                       unicode='\r', mod=0, scancode=36,
-                                       window=None, test=True)
-        else:
-            event = pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_RIGHT,
-                                       unicode=u'\uf703', mod=0, scancode=124,
-                                       window=None, test=True)
-        return event
-
-    def create_next_event(self):
-        """Create a pygame event to select the next widget.
+    def is_enabled(self):
+        """Return True if the menu is shown.
         """
-        return pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_MOVE_UP,
-                                  unicode=u'\uf701', mod=0, scancode=125,
-                                  window=None, test=True)
+        return self._main_menu.is_enabled()
 
-    def create_back_event(self):
-        """Create a pygame event to back to the previous menu.
-        """
-        return pygame.event.Event(pygame.KEYDOWN, key=pgm.controls.KEY_BACK,
-                                  unicode=u'\x1b', mod=0, scancode=53,
-                                  window=None, test=True)
+    def resize(self, size):
+        self._main_menu.resize(size[0], size[1])
 
-    def process(self, events):
+    def update(self, events):
         """Process the events related to the menu.
         """
-        if not self._keyboard.is_enabled():
-            self._main_menu.update(events)
-            if self._main_menu.is_enabled():  # Menu may have been closed
-                self._main_menu.draw(self.win.surface)
-                selected = self._main_menu.get_current().get_selected_widget()
-                if isinstance(selected, pgm.widgets.TextInput) and self.cfg.getboolean('GENERAL', 'vkeyboard'):
-                    for event in events:
-                        if (event.type == pygame.MOUSEBUTTONDOWN or event.type == pygame.FINGERDOWN)\
-                                and selected.get_scrollarea().collide(selected, event):
-                            self._keyboard.enable()
-                            if isinstance(selected, pgm.widgets.ColorInput):
-                                self._keyboard.set_text(",".join([str(c) for c in selected.get_value()]))
-                            else:
-                                self._keyboard.set_text(selected.get_value())
-                            return
-        else:
-            for event in events:
-                if (event.type == pygame.MOUSEBUTTONDOWN and event.button in (1, 2, 3)
-                        or event.type == pygame.FINGERDOWN)\
-                        and not self._keyboard.get_rect().collidepoint(get_event_pos(self.win.display_size, event)):
-                    self._keyboard.disable()
-                    self._keyboard.draw()
-                    return
-                elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
-                    self._keyboard.disable()
-                    self._keyboard.draw()
-                    return
-
-            self._keyboard.update(events)
-            self._keyboard.draw(self.win.surface)
+        self._main_menu.update(events)
+        if self._main_menu.is_enabled():  # Menu may have been closed
+            selected = self._main_menu.get_current().get_selected_widget()
+            if isinstance(selected, pgm.widgets.TextInput) and self.cfg.getboolean('GENERAL', 'vkeyboard'):
+                for event in events:
+                    if (event.type == pygame.MOUSEBUTTONDOWN or event.type == pygame.FINGERDOWN)\
+                            and selected.get_scrollarea().collide(selected, event):
+                        self._keyboard.enable()
+                        if isinstance(selected, pgm.widgets.ColorInput):
+                            self._keyboard.set_text(",".join([str(c) for c in selected.get_value()]))
+                        else:
+                            self._keyboard.set_text(selected.get_value())
+                        return
+
+    def draw(self, surface):
+        return self._main_menu.draw(surface)
diff --git a/pibooth/view/pygame/scenes/__init__.py b/pibooth/view/pygame/scenes/__init__.py
new file mode 100644
index 00000000..cc2af6fb
--- /dev/null
+++ b/pibooth/view/pygame/scenes/__init__.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+
+from pibooth.view.pygame.scenes.wait import WaitScene
+from pibooth.view.pygame.scenes.choose import ChooseScene
+from pibooth.view.pygame.scenes.chosen import ChosenScene
+from pibooth.view.pygame.scenes.preview import PreviewScene
+from pibooth.view.pygame.scenes.capture import CaptureScene
+from pibooth.view.pygame.scenes.processing import ProcessingScene
+from pibooth.view.pygame.scenes.print import PrintScene
+from pibooth.view.pygame.scenes.finish import FinishScene
+from pibooth.view.pygame.scenes.failsafe import FailsafeScene
+
+SCENES = {
+    'wait': WaitScene,
+    'choose': ChooseScene,
+    'chosen': ChosenScene,
+    'preview': PreviewScene,
+    'capture': CaptureScene,
+    'processing': ProcessingScene,
+    'print': PrintScene,
+    'finish': FinishScene,
+    'failsafe': FailsafeScene
+}
+
+
+def get_scene(name):
+    """Return the scene according to the given name.
+
+    :param name: name of the scene
+    :type name: str
+    """
+    if name not in SCENES:
+        raise ValueError(f"Unknown scene '{name}'")
+
+    return SCENES[name](name)
diff --git a/pibooth/view/pygame/scenes/capture.py b/pibooth/view/pygame/scenes/capture.py
new file mode 100644
index 00000000..34efd77c
--- /dev/null
+++ b/pibooth/view/pygame/scenes/capture.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+
+from pibooth.utils import PollingTimer
+from pibooth.view.pygame.sprites import BasePygameScene
+
+
+class CaptureScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(CaptureScene, self).__init__(name)
+        self.count_flash = 0
+        self.is_flash_on = False
+        self.saved_skin = None
+
+        # Seconds between each flash
+        self.flash_timer = PollingTimer(0.3)
+
+    def resize(self, size):
+        # Capture
+        self.image.set_rect(self.rect.width // 6, 10,
+                            self.rect.width * 2 // 3, self.rect.height * 7 // 8)
+
+    def update(self, events):
+        if self.flash_timer.is_started() and self.flash_timer.is_timeout():
+            self.trigger_flash()
+        super(CaptureScene, self).update(events)
+
+    def set_capture_number(self, current, total):
+        self.count_flash = 0
+
+    def trigger_flash(self):
+        self.flash_timer.start()
+        self.is_flash_on = not self.is_flash_on
+        if self.is_flash_on:
+            self.count_flash += 1
+            self.saved_skin = self.background.get_skin()
+            self.background.set_skin((255, 255, 255))
+        else:
+            self.background.set_skin(self.saved_skin)
diff --git a/pibooth/view/pygame/scenes/choose.py b/pibooth/view/pygame/scenes/choose.py
new file mode 100644
index 00000000..83b0ac3f
--- /dev/null
+++ b/pibooth/view/pygame/scenes/choose.py
@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+
+from pygame_imslider import ImSlider, ImSliderRenderer, STYPE_LOOP
+
+from pibooth import evts
+from pibooth import pictures
+from pibooth.language import get_translated_text
+from pibooth.view.pygame.sprites import BasePygameScene, LeftArrowSprite, RightArrowSprite, TextSprite
+
+
+class Renderer(ImSliderRenderer):
+
+    def __init__(self, scene):
+        super(Renderer, self).__init__(arrow_color=((182, 183, 184), (124, 183, 62)),
+                                       dot_color=((182, 183, 184), (124, 183, 62)),
+                                       slide_color=None,
+                                       selection_color=(124, 183, 62),
+                                       selection_page_color=(180, 220, 130),
+                                       background_color=None)
+        self.scene = scene
+
+    @property
+    def arrow_color(self):
+        return (self.scene.text_color, tuple(int(c * 0.5) for c in self.scene.text_color))
+
+    @arrow_color.setter
+    def arrow_color(self, color):
+        pass
+
+    @property
+    def dot_color(self):
+        return self.arrow_color
+
+    @dot_color.setter
+    def dot_color(self, color):
+        pass
+
+    @property
+    def slide_color(self):
+        return self.scene.background.get_color(0.7)
+
+    @slide_color.setter
+    def slide_color(self, color):
+        pass
+
+    def draw_background(self, surface):
+        surface.blit(self.scene.background.image, (0, 0), self.scene.slider.get_rect())
+
+
+class ChooseScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(ChooseScene, self).__init__(name)
+        self.choices = ()
+        self.slider = ImSlider((200, 100), focus=False, renderer=Renderer(self), stype=STYPE_LOOP)
+        self.text = self.add_sprite(TextSprite(get_translated_text('choose')))
+        self.left_arrow = self.add_sprite(LeftArrowSprite())
+        self.right_arrow = self.add_sprite(RightArrowSprite())
+
+        self.right_arrow.set_skin('arrow_double.png')
+        self.left_arrow.on_pressed = evts.post_button_capture_event
+        self.right_arrow.on_pressed = evts.post_button_print_event
+
+    def resize(self, size):
+        # Slider
+        slider_width, slider_height = self.rect.width * 3 // 4, self.rect.height * 6 // 8
+        x, y = (self.rect.width - slider_width) // 2, (self.rect.height - slider_height) // 2
+        if self.arrow_location in (self.ARROW_BOTTOM, self.ARROW_TOP):
+            self.slider.set_arrows_visible(False)
+        else:
+            self.slider.set_arrows_visible(True)
+        self.slider.set_position(x, y)
+        self.slider.set_size(slider_width, slider_height)
+
+        # Text
+        self.text.set_text(get_translated_text('choose'))  # In case of text has changed
+        if self.arrow_location == self.ARROW_TOP:
+            self.text.set_rect(10, self.rect.bottom - y, self.rect.width - 20, y - 10)
+        else:
+            self.text.set_rect(10, 10, self.rect.width - 20, y - 10)
+
+        # Left arrow
+        size = (self.rect.height * 0.15, self.rect.height * 0.15)
+        if self.arrow_location == self.ARROW_TOUCH:
+            self.left_arrow.set_skin('touch_thumb.png')
+            x = self.rect.right - size[0] - 10
+        else:
+            self.left_arrow.set_skin('arrow.png')
+            x = self.rect.left + self.rect.width // 4 - size[0] // 2
+        if self.arrow_location == self.ARROW_TOP:
+            y = self.rect.top + 10
+        else:
+            y = self.rect.bottom - size[1] - 10
+        self.left_arrow.set_rect(x, y, size[0], size[1])
+
+        # Right arrow
+        size = (self.rect.width * 0.1, self.rect.height * 0.1)
+        if self.arrow_location == self.ARROW_TOUCH:
+            self.right_arrow.hide()
+        elif self.arrow_location in (self.ARROW_BOTTOM, self.ARROW_TOP):
+            x = self.rect.right - self.rect.width // 4 - size[0] // 2
+            if self.arrow_location == self.ARROW_TOP:
+                y = self.rect.top + 10
+            else:
+                y = self.rect.bottom - size[1] - 10
+            self.right_arrow.set_rect(x, y, size[0], size[1])
+
+    def update(self, events):
+        super(ChooseScene, self).update(events)
+        self.slider.update(events)
+
+    def draw(self, surface, force):
+        rects = super(ChooseScene, self).draw(surface, force)
+        rects += self.slider.draw(surface, force)
+        return rects
+
+    def next(self):
+        """Display next possible layout.
+        """
+        self.slider.on_next()
+
+    def set_choices(self, choices):
+        """Set the list of possible number of captures.
+        """
+        if choices != self.choices:
+            # Reload pictures
+            self.choices = choices
+            self.slider.load_images([pictures.get_layout_asset(
+                c, self.background.get_color(), self.text_color) for c in choices])
+
+    def get_selection(self):
+        """Return curretly selected number of captures.
+        """
+        return self.choices[self.slider.get_index()]
diff --git a/pibooth/view/pygame/scenes/chosen.py b/pibooth/view/pygame/scenes/chosen.py
new file mode 100644
index 00000000..b3ad2c9e
--- /dev/null
+++ b/pibooth/view/pygame/scenes/chosen.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+
+from pibooth.language import get_translated_text
+from pibooth.view.pygame.sprites import BasePygameScene, TextSprite
+
+
+class ChosenScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(ChosenScene, self).__init__(name)
+        self.choice = 0
+        self.text = self.add_sprite(TextSprite(get_translated_text('chosen')))
+
+    def get_text(self):
+        texts = []
+        if get_translated_text('chosen'):
+            texts.append(get_translated_text('chosen'))
+        if get_translated_text(str(self.choice)):
+            texts.append(get_translated_text(str(self.choice)))
+        return "\n".join(texts)
+
+    def resize(self, size):
+        self.text.set_text(self.get_text())  # In case of text has changed
+        self.text.set_rect(*self.rect.inflate(-100, -100))
+
+    def set_selected_choice(self, choice):
+        if self.choice != choice:
+            self.choice = choice
+            self.text.set_text(self.get_text())
diff --git a/pibooth/view/pygame/scenes/failsafe.py b/pibooth/view/pygame/scenes/failsafe.py
new file mode 100644
index 00000000..887c744f
--- /dev/null
+++ b/pibooth/view/pygame/scenes/failsafe.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+
+from pibooth.language import get_translated_text
+from pibooth.view.pygame.sprites import BasePygameScene, TextSprite
+
+
+class FailsafeScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(FailsafeScene, self).__init__(name)
+        self.text = self.add_sprite(TextSprite(get_translated_text('oops')))
+
+    def resize(self, size):
+        self.text.set_text(get_translated_text('oops'))  # In case of text has changed
+        self.text.set_rect(*self.rect.inflate(-100, -100))
diff --git a/pibooth/view/pygame/scenes/finish.py b/pibooth/view/pygame/scenes/finish.py
new file mode 100644
index 00000000..02d9847a
--- /dev/null
+++ b/pibooth/view/pygame/scenes/finish.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+
+from pibooth.language import get_translated_text
+from pibooth.view.pygame.sprites import BasePygameScene, ImageSprite, TextSprite
+
+
+class FinishScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(FinishScene, self).__init__(name)
+        self.left_image = self.add_sprite(ImageSprite('finish_left.png'))
+        self.right_image = self.add_sprite(ImageSprite('finish_right.png'))
+        self.text = self.add_sprite(TextSprite(get_translated_text('finished')))
+
+    def resize(self, size):
+        # Text
+        self.text.set_text(get_translated_text('finished'))  # In case of text has changed
+        self.text.set_rect(*self.rect.inflate(-self.rect.width // 2, -self.rect.height//2))
+
+        # Final picture
+        self.image.set_rect(*self.rect.inflate(-100, 0))
+
+    def set_image(self, image):
+        super(FinishScene, self).set_image(image)
+
+        if not image:
+            self.text.show()
+
+            padding = self.rect.width // 4
+            # Left image
+            size = (padding - 20, self.rect.height * 0.3)
+            self.left_image.set_rect(10, self.rect.centery - size[1], size[0], size[1])
+
+            # Right image
+            size = (padding - 20, self.rect.height * 0.3)
+            self.right_image.set_rect(self.rect.right - size[0] - 10,
+                                      self.rect.centery + size[1] // 2, size[0], size[1])
+        else:
+            self.text.hide()
+
+            # Left image
+            self.left_image.set_rect(0, 0, 100, 100)
+
+            # Right image
+            self.right_image.set_rect(self.rect.right - 100, self.rect.bottom - 100, 100, 100)
diff --git a/pibooth/view/pygame/scenes/preview.py b/pibooth/view/pygame/scenes/preview.py
new file mode 100644
index 00000000..1fc6add1
--- /dev/null
+++ b/pibooth/view/pygame/scenes/preview.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+
+from pibooth.view.pygame.sprites import BasePygameScene, ImageSprite, DotsSprite
+
+
+class PreviewScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(PreviewScene, self).__init__(name)
+        self.left_image = self.add_sprite(ImageSprite('capture_left.png'))
+        self.right_image = self.add_sprite(ImageSprite('capture_right.png'))
+        self.dots = self.add_sprite(DotsSprite(4))
+
+    def resize(self, size):
+        # Preview capture
+        self.image.set_rect(self.rect.width // 6, 10,
+                            self.rect.width * 2 // 3, self.rect.height * 7 // 8)
+
+        # Left image
+        height = self.rect.height // 4
+        size = (height, height)
+        self.left_image.set_rect(10, self.rect.bottom - size[1], size[0] * 1.5, size[1])
+
+        # Right image
+        self.right_image.set_rect(self.rect.right - size[0] - 10, self.rect.bottom - size[1], size[0], size[1])
+
+        # Dots
+        self.dots.set_rect(self.rect.width // 6, self.rect.bottom - self.rect.height // 8 + 10,
+                           self.rect.width * 2 // 3, self.rect.height // 8 - 10)
+
+    def set_capture_number(self, current, total):
+        self.dots.set_status(current, total)
diff --git a/pibooth/view/pygame/scenes/print.py b/pibooth/view/pygame/scenes/print.py
new file mode 100644
index 00000000..17579f5d
--- /dev/null
+++ b/pibooth/view/pygame/scenes/print.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+
+import pygame
+from pibooth import pictures, evts
+from pibooth.language import get_translated_text
+from pibooth.view.pygame.sprites import BasePygameScene, LeftArrowSprite, RightArrowSprite, TextSprite
+
+
+class PrintScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(PrintScene, self).__init__(name)
+        self.left_arrow = self.add_sprite(LeftArrowSprite())
+        self.right_arrow = self.add_sprite(RightArrowSprite())
+        self.text = self.add_sprite(TextSprite(get_translated_text('print')))
+        self.text_print = self.add_sprite(TextSprite(get_translated_text('print_forget')))
+
+        self.text.on_pressed = evts.post_button_capture_event
+        self.left_arrow.on_pressed = evts.post_button_capture_event
+        self.image.on_pressed = evts.post_button_print_event
+        self.text_print.on_pressed = evts.post_button_print_event
+        self.right_arrow.on_pressed = evts.post_button_print_event
+
+    def resize(self, size):
+        # Previous picture
+        self.image.set_rect(0, 0, self.rect.width // 2, self.rect.height)
+
+        # Take picture text
+        text_border = 20
+        self.text.set_text(get_translated_text('print'))  # In case of text has changed
+        if self.arrow_location == self.ARROW_HIDDEN:
+            self.text.set_align(pictures.ALIGN_CENTER)
+            self.text.set_rect(self.rect.width // 2 + text_border, text_border,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height - 2 * text_border)
+        elif self.arrow_location == self.ARROW_BOTTOM:
+            self.text.set_align(pictures.ALIGN_BOTTOM_CENTER)
+            self.text.set_rect(self.rect.width // 2 + text_border, text_border,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height * 0.6 - text_border)
+        elif self.arrow_location == self.ARROW_TOUCH:
+            self.text.set_align(pictures.ALIGN_BOTTOM_CENTER)
+            self.text.set_rect(self.rect.width // 2 + text_border, text_border,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height * 0.4 - text_border)
+        else:
+            self.text.set_align(pictures.ALIGN_TOP_CENTER)
+            self.text.set_rect(self.rect.width // 2 + text_border, self.rect.height * 0.4,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height * 0.6 - text_border)
+
+        # Forget text
+        self.text_print.set_text(get_translated_text('print_forget'))  # In case of text has changed
+        rect = pygame.Rect(self.rect.centerx, 0, self.rect.width * 0.2, self.rect.height * 0.2)
+        if self.arrow_location == self.ARROW_TOP:
+            rect.top = self.rect.height * 0.12
+        else:
+            rect.bottom = self.rect.height - self.rect.height * 0.12
+        self.text_print.set_rect(*rect)
+
+        # Left arrow
+        size = (self.rect.width * 0.1, self.rect.height * 0.1)
+        if self.arrow_location == self.ARROW_TOP:
+            y = self.rect.top + 10
+        else:
+            y = self.rect.bottom - size[1] - 10
+        if self.arrow_location == self.ARROW_TOUCH:
+            self.left_arrow.set_skin('touch.png')
+            self.left_arrow.set_angle(60)
+            self.left_arrow.set_flip(hflip=False)
+        elif self.arrow_location in (self.ARROW_BOTTOM, self.ARROW_TOP):
+            self.left_arrow.set_skin('arrow.png')
+            self.left_arrow.set_angle(-60)
+        if self.arrow_location != self.ARROW_HIDDEN:
+            self.left_arrow.set_rect(self.rect.centerx, y, size[0], size[1])
+
+        # Right arrow
+        if self.arrow_location == self.ARROW_TOUCH:
+            self.right_arrow.set_skin('touch_print.png')
+            size = (self.rect.width * 0.15, self.rect.height * 0.15)
+            x = self.rect.centerx + self.rect.width * 0.2
+            y = self.rect.height // 2
+        elif self.arrow_location in (self.ARROW_BOTTOM, self.ARROW_TOP):
+            self.right_arrow.set_skin('arrow.png')
+            size = (self.rect.width * 0.1, self.rect.height * 0.3)
+            x = self.rect.centerx + self.rect.width // 4 - size[0] // 2
+            if self.arrow_location == self.ARROW_TOP:
+                y = self.rect.top + 10
+            else:
+                y = self.rect.bottom - size[1] - 10
+        if self.arrow_location != self.ARROW_HIDDEN:
+            self.right_arrow.set_rect(x, y, size[0], size[1])
diff --git a/pibooth/view/pygame/scenes/processing.py b/pibooth/view/pygame/scenes/processing.py
new file mode 100644
index 00000000..27c15221
--- /dev/null
+++ b/pibooth/view/pygame/scenes/processing.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+from pibooth.language import get_translated_text
+from pibooth.view.pygame.sprites import BasePygameScene, TextSprite
+
+
+class ProcessingScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(ProcessingScene, self).__init__(name)
+        self.set_image('processing.png')
+        self.text = self.add_sprite(TextSprite(get_translated_text('processing')))
+
+    def resize(self, size):
+        # Preview picture
+        self.image.set_rect(*self.rect)
+
+        # Text
+        self.text.set_text(get_translated_text('processing'))  # In case of text has changed
+        self.text.set_rect(10, self.rect.height * 3 // 4, self.rect.width - 20, self.rect.height // 4)
diff --git a/pibooth/view/pygame/scenes/wait.py b/pibooth/view/pygame/scenes/wait.py
new file mode 100644
index 00000000..5f2d223c
--- /dev/null
+++ b/pibooth/view/pygame/scenes/wait.py
@@ -0,0 +1,128 @@
+# -*- coding: utf-8 -*-
+
+import pygame
+from pibooth.utils import PollingTimer
+from pibooth import pictures, evts
+from pibooth.language import get_translated_text
+from pibooth.view.pygame.sprites import (BasePygameScene, LeftArrowSprite, RightArrowSprite,
+                                         ImageSprite, TextSprite)
+
+
+class WaitScene(BasePygameScene):
+
+    def __init__(self, name):
+        super(WaitScene, self).__init__(name)
+        self.left_arrow = self.add_sprite(LeftArrowSprite())
+        self.right_arrow = self.add_sprite(RightArrowSprite())
+        self.text = self.add_sprite(TextSprite(get_translated_text('intro')))
+        self.text_print = self.add_sprite(TextSprite(get_translated_text('intro_print')))
+        self.image_check = self.add_sprite(ImageSprite('check.png'))
+        self.image_check.hide()
+
+        self.printer_ongoing_timer = PollingTimer(1)
+
+        self.text.on_pressed = evts.post_button_capture_event
+        self.left_arrow.on_pressed = evts.post_button_capture_event
+        self.image.on_pressed = self.on_event
+        self.text_print.on_pressed = evts.post_button_print_event
+        self.right_arrow.on_pressed = evts.post_button_print_event
+
+    def on_event(self):
+        self.image.hide()  # Do not show image after click, there is a timer before
+        evts.post_button_print_event()
+
+    def resize(self, size):
+        # Previous picture
+        self.image.set_rect(self.rect.centerx, 0, self.rect.width // 2, self.rect.height)
+        self.image_check.set_rect(*self.image.rect.inflate(-self.image.rect.width // 2,
+                                                           -self.image.rect.width // 2))
+
+        # Take picture text
+        text_border = 20
+        self.text.set_text(get_translated_text('intro'))  # In case of text has changed
+        if self.arrow_location == self.ARROW_HIDDEN:
+            self.text.set_align(pictures.ALIGN_CENTER)
+            self.text.set_rect(text_border, text_border,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height - 2 * text_border)
+        elif self.arrow_location == self.ARROW_BOTTOM:
+            self.text.set_align(pictures.ALIGN_BOTTOM_CENTER)
+            self.text.set_rect(text_border, text_border,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height * 0.6 - text_border)
+        elif self.arrow_location == self.ARROW_TOUCH:
+            self.text.set_align(pictures.ALIGN_BOTTOM_CENTER)
+            self.text.set_rect(text_border, text_border,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height * 0.4 - text_border)
+        else:
+            self.text.set_align(pictures.ALIGN_TOP_CENTER)
+            self.text.set_rect(text_border, self.rect.height * 0.4,
+                               self.rect.width // 2 - 2 * text_border,
+                               self.rect.height * 0.6 - text_border)
+
+        # Print text
+        self.text_print.set_text(get_translated_text('intro_print'))  # In case of text has changed
+        rect = pygame.Rect(self.rect.width * 0.3, 0, self.rect.width * 0.2, self.rect.height * 0.2)
+        if self.arrow_location == self.ARROW_TOP:
+            rect.top = self.rect.height * 0.11
+        else:
+            rect.bottom = self.rect.height - self.rect.height * 0.11
+        self.text_print.set_rect(*rect)
+
+        # Left arrow
+        if self.arrow_location == self.ARROW_TOUCH:
+            self.left_arrow.set_skin('touch_camera.png')
+            size = (self.rect.width * 0.15, self.rect.height * 0.15)
+            x = self.rect.width * 0.2
+            y = self.rect.height // 2
+        elif self.arrow_location in (self.ARROW_BOTTOM, self.ARROW_TOP):
+            self.left_arrow.set_skin('arrow.png')
+            size = (self.rect.width * 0.1, self.rect.height * 0.3)
+            x = self.rect.left + self.rect.width // 4 - size[0] // 2
+            if self.arrow_location == self.ARROW_TOP:
+                y = self.rect.top + 10
+            else:
+                y = self.rect.bottom - size[1] - 10
+        if self.arrow_location != self.ARROW_HIDDEN:
+            self.left_arrow.set_rect(x, y, size[0], size[1])
+
+        # Right arrow
+        size = (self.rect.width * 0.1, self.rect.height * 0.1)
+        x = self.rect.centerx - size[0]
+        if self.arrow_location == self.ARROW_TOP:
+            y = self.rect.top + 10
+        else:
+            y = self.rect.bottom - size[1] - 10
+        if self.arrow_location == self.ARROW_TOUCH:
+            self.right_arrow.set_skin('touch_print.png')
+            self.right_arrow.set_flip(hflip=False)
+        elif self.arrow_location in (self.ARROW_BOTTOM, self.ARROW_TOP):
+            self.right_arrow.set_skin('arrow.png')
+            self.right_arrow.set_angle(-60)
+        if self.arrow_location != self.ARROW_HIDDEN:
+            self.right_arrow.set_rect(x, y, size[0], size[1])
+
+    def update(self, events):
+        if evts.find_event(events, evts.EVT_PIBOOTH_BTN_PRINT):
+            self.image.hide()
+            self.image_check.show()
+            self.printer_ongoing_timer.start()
+
+        if self.printer_ongoing_timer.is_started() and self.printer_ongoing_timer.is_timeout():
+            self.image.show()
+            self.image_check.hide()
+            self.printer_ongoing_timer.reset()
+        super(WaitScene, self).update(events)
+
+    def update_print_action(self, enabled=True):
+        if enabled:
+            self.text_print.show()
+            if self.arrow_location != self.ARROW_HIDDEN:
+                self.right_arrow.show()
+            self.image.on_pressed = self.on_event
+        else:
+            self.text_print.hide()
+            if self.arrow_location != self.ARROW_HIDDEN:
+                self.right_arrow.hide()
+            self.image.on_pressed = None
diff --git a/pibooth/view/pygame/sprites.py b/pibooth/view/pygame/sprites.py
new file mode 100644
index 00000000..c34e19ba
--- /dev/null
+++ b/pibooth/view/pygame/sprites.py
@@ -0,0 +1,634 @@
+# -*- coding: utf-8 -*-
+
+import os.path as osp
+import PIL
+import pygame
+
+from pibooth import pictures
+from pibooth import evts
+from pibooth.view.base import BaseScene
+
+
+class OutlinesSprite(pygame.sprite.DirtySprite):
+    """Outlines Sprite. Paint a colored rectange around the given
+    sprite.
+    """
+
+    def __init__(self, sprite, color=(255, 0, 0)):
+        """
+        :param sprite: sprite on which outlines are drawn
+        :type sprite: object
+        :param color: RGB color tuple for the outlines
+        :type color: tuple
+        """
+        super(OutlinesSprite, self).__init__()
+        self.image = pygame.Surface((10, 10), pygame.SRCALPHA, 32)
+        self.rect = sprite.rect
+        self.color = color
+        self.visible = 0
+        self._sp = sprite
+        self._sp.outlines = self
+        self.enabled = True
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}(rect={tuple(self.rect)}"
+
+    def enable(self):
+        """Show outlines (only if reference sprite is visible).
+        """
+        self.enabled = True
+        if self._sp.visible and not self.visible:
+            self.visible = 1
+
+    def disable(self):
+        """Hide outlines.
+        """
+        self.enabled = False
+        if self.visible:
+            self.visible = 0
+
+    def update(self, events):
+        """Draw outlines only if visible.
+        """
+        if self._sp.dirty and self.visible:
+            self.dirty = 1
+            self.image = pygame.Surface(self.rect.size, pygame.SRCALPHA, 32)
+            pygame.draw.rect(self.image, self.color, (0, 0, self.rect.width, self.rect.height), 2)
+
+
+class BaseSprite(pygame.sprite.DirtySprite):
+
+    """Base sprite for any graphical element. It handles common attributes
+    and methods:
+      - display: manage when sprite is displayed
+      - color: manage color rendering
+      - resizing: manage pygame.Rect dimension
+      - outline: manage a colored rectangle around the sprite
+      - event: manage pressed action
+    """
+
+    def __init__(self, size):
+        super(BaseSprite, self).__init__()
+        self.image = None
+        self.rect = pygame.Rect((0, 0), size)
+        self.color = None
+        self.outlines = None  # Dynamically set by outlines sprite class
+        self.pressed = 0
+        self.on_pressed = None
+
+    def show(self):
+        """Show image.
+        """
+        if not self.visible:
+            self.visible = 1
+            if self.outlines and self.outlines.enabled and not self.outlines.visible:
+                self.outlines.visible = 1
+
+    def hide(self):
+        """Hide image.
+        """
+        if self.visible:
+            self.visible = 0
+            if self.outlines and self.outlines.visible:
+                self.outlines.visible = 0
+
+    def set_rect(self, x, y, width, height):
+        """Set the sprite absolute position and size.
+
+        :param x: position x
+        :type x: int
+        :param y: position y
+        :type y: int
+        :param width: background width
+        :type width: int
+        :param height: background height
+        :type height: int
+        """
+        if self.rect.topleft != (int(x), int(y)):
+            self.rect.topleft = (x, y)
+            self.dirty = 1
+        if self.rect.size != (int(width), int(height)):
+            self.rect.size = (width, height)
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def set_color(self, color):
+        """Re-colorize the skin.
+
+        :param color: RGB color tuple
+        :type color: tuple
+        """
+        if color != self.color:
+            self.color = color
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def get_color(self, factor=1):
+        """Return image main color.
+
+        :param factor: more or less dark, should be > 0 and < 1
+        :type factor: int
+        """
+        if self.color:
+            return tuple(min(int(c * abs(factor)), 255) for c in self.color)
+        else:
+            return pygame.transform.average_color(self.image)
+
+    def set_pressed(self, state):
+        """Set the pressed state (1 for pressed 0 for released)
+        and redraws it.
+
+        :param state: new sprite state.
+        :type state: bool.
+        """
+        if self.pressed != int(state):
+            self.pressed = int(state)
+            if self.on_pressed is not None:
+                if self.pressed and self.visible:
+                    self.visible = 0
+                else:
+                    self.visible = 1
+                    # Trigger callback when press is released
+                    self.on_pressed()
+
+
+class ImageSprite(BaseSprite):
+    """Image Sprite. Handle transformation on a source image which can be:
+     - RGB color tuple
+     - path to an image
+     - PIL image object
+     - Pygame surface object
+    """
+
+    def __init__(self, skin=None, size=(10, 10), colorize=True):
+        """
+        :param skin: image file path, RGB color tuple,  PIL image or Pygame surface
+        :type skin: str or tuple or object
+        :param size: size tuple (width, height) of the image.
+        :type size: tuple
+        :param colorize: recolorize picture if a color is set.
+        :type colorize: tuple
+        """
+        super(ImageSprite, self).__init__(size)
+        self.image_orig = None
+        self.path = None
+        self.crop = False
+        self.hflip = False
+        self.vflip = False
+        self.angle = 0
+        self.colorize = colorize
+        if skin:
+            self.set_skin(skin)
+
+    def __repr__(self):
+        if self.path:
+            path = osp.basename(self.path)
+        else:
+            path = ''
+        return f"{self.__class__.__name__}(path='{path}', rect={tuple(self.rect)})"
+
+    def set_skin(self, skin):
+        """Set skin used to fill the sprite. Skin can be:
+
+            - RGB color tuple
+            - path to an image
+            - PIL image object
+            - Pygame surface object
+
+        :param skin: image file path, RGB color tuple,  PIL image or Pygame surface
+        :type skin: str or tuple or object
+        """
+        if isinstance(skin, str):
+            if skin != self.path:
+                self.path, self.image = skin, None  # Force rendering
+                self.image_orig = None
+                self.dirty = 1
+        elif isinstance(skin, (tuple, list)):
+            assert len(skin) == 3, "Length of 3 is required for RGB tuple"
+            if skin != self.image_orig:
+                self.path, self.image = None, None  # Force rendering
+                self.image_orig = skin
+                self.dirty = 1
+        else:
+            assert isinstance(skin, (PIL.Image.Image, pygame.Surface)), "PIL Image or Pygame Surface is required"
+            if skin != self.image_orig:
+                self.path, self.image = None, None  # Force rendering
+                self.image_orig = skin
+                self.dirty = 1
+
+    def get_skin(self):
+        """Return the current skin.
+        """
+        return self.path or self.image_orig
+
+    def set_crop(self, crop=True):
+        """Crop the skin to fit sprite size.
+
+        :param crop: crop image to fit aspect ratio of the size
+        :type crop: bool
+        """
+        if crop != self.crop:
+            self.crop = crop
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def set_flip(self, hflip=None, vflip=None):
+        """Flip the skin vertically or horizontally.
+
+        :param hflip: apply an horizontal flip
+        :type hflip: bool
+        :param vflip: apply a vertical flip
+        :type vflip: bool
+        """
+        if hflip is not None and hflip != self.hflip:
+            self.hflip = hflip
+            self.image = None  # Force rendering
+            self.dirty = 1
+        if vflip is not None and vflip != self.vflip:
+            self.vflip = vflip
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def set_angle(self, angle=0):
+        """Rotate the skin.
+
+        :param angle: angle of rotation of the image
+        :type angle: int
+        """
+        if angle != self.angle:
+            self.angle = angle
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def update(self, events):
+        """Draw image if has changed and visible.
+        """
+        if self.image is None and self.visible:
+            if self.image_orig is None:
+                if self.path:
+                    self.image_orig = pictures.load_pygame_image(self.path)
+                else:
+                    raise ValueError("Path to image is missing")
+
+            if isinstance(self.image_orig, (tuple, list)):
+                self.image = pygame.Surface(self.rect.size, pygame.SRCALPHA, 32)
+                self.image.fill(self.image_orig)
+            elif isinstance(self.image_orig, PIL.Image.Image):
+                surface = pygame.image.frombuffer(self.image_orig.tobytes(),
+                                                  self.image_orig.size, self.image_orig.mode)
+                self.image = pictures.transform_pygame_image(surface, self.rect.size, hflip=self.hflip,
+                                                             vflip=self.vflip, angle=self.angle, crop=self.crop,
+                                                             color=self.color if self.colorize else None)
+            else:
+                self.image = pictures.transform_pygame_image(self.image_orig, self.rect.size, hflip=self.hflip,
+                                                             vflip=self.vflip, angle=self.angle, crop=self.crop,
+                                                             color=self.color if self.colorize else None)
+
+
+class TextSprite(BaseSprite):
+
+    """Text Sprite.
+    """
+
+    def __init__(self, text='', size=(10, 10)):
+        """
+        :param size: size tuple (width, height) of the image.
+        :type size: tuple
+        :param path: image file path
+        :type path: str
+        """
+        super(TextSprite, self).__init__(size)
+        self.text = text
+        self.color = (255, 255, 255)
+        self.align = pictures.ALIGN_CENTER
+        if text is not None:
+            self.set_text(text)
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}(text='{self.text}', rect={tuple(self.rect)})"
+
+    def set_text(self, text):
+        """Set text.
+
+        :param text: text to display
+        :type text: str
+        """
+        if text != self.text:
+            self.text = text
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def set_align(self, align):
+        """Set text alignment.
+
+        :param align: text to display
+        :type align: str
+        """
+        if align != self.align:
+            self.align = align
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def update(self, events):
+        """Draw image if has changed and visible.
+        """
+        if self.image is None and self.visible:
+            self.image = pictures.text_to_pygame_image(self.text, self.rect.size, self.color, self.align)
+
+
+class ArrowSprite(ImageSprite):
+
+    """Image Sprite dedicated to display an arrow on a specific
+    location and with an offset.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(ArrowSprite, self).__init__(*args, **kwargs)
+        self.location = BaseScene.ARROW_BOTTOM
+        self.offset = 0
+
+    def set_location(self, location):
+        if location != self.location:
+            self.location = location
+            self.dirty = 1
+
+    def set_offset(self, offset):
+        if offset != self.offset:
+            self.offset = offset
+            self.dirty = 1
+
+
+class LeftArrowSprite(ArrowSprite):
+
+    """Image Sprite dedicated to display the left arrow.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(LeftArrowSprite, self).__init__(*args, **kwargs)
+        self.set_skin('arrow.png')
+
+    def set_location(self, location):
+        super(LeftArrowSprite, self).set_location(location)
+        if location == BaseScene.ARROW_BOTTOM:
+            self.set_flip(vflip=False)
+            self.show()
+        elif location == BaseScene.ARROW_TOP:
+            self.set_flip(vflip=True)
+            self.show()
+        elif location == BaseScene.ARROW_TOUCH:
+            self.show()
+        else:
+            self.hide()
+
+    def set_rect(self, x, y, width, height):
+        """Set the sprite absolute position and size.
+        """
+        x -= self.offset
+        super(LeftArrowSprite, self).set_rect(x, y, width, height)
+
+
+class RightArrowSprite(LeftArrowSprite):
+
+    """Image Sprite dedicated to display the right arrow.
+    """
+
+    def __init__(self, *args, **kwargs):
+        super(RightArrowSprite, self).__init__(*args, **kwargs)
+        self.set_flip(hflip=True)
+
+    def set_rect(self, x, y, width, height):
+        """Set the sprite absolute position and size.
+        """
+        x += self.offset
+        ArrowSprite.set_rect(self, x, y, width, height)
+
+
+class DotsSprite(BaseSprite):
+
+    """Dot Sprite to count current capture.
+    """
+
+    def __init__(self, nbr_dots=4):
+        super(DotsSprite, self).__init__((100, 50))
+        self.dots = []
+        self.filled_orig = pictures.load_pygame_image('dot_filled.png')
+        self.empty_orig = pictures.load_pygame_image('dot.png')
+        self.current = 0
+        self.total = nbr_dots
+
+    def set_status(self, current, total):
+        if self.total != total:
+            self.total = total
+            self.image = None  # Force rendering
+            self.dirty = 1
+        if self.current != current:
+            self.current = current
+            self.image = None  # Force rendering
+            self.dirty = 1
+
+    def update(self, events):
+        if self.image is None and self.visible:
+            self.image = pygame.Surface(self.rect.size, pygame.SRCALPHA, 32)
+            border = 20
+            side = min(self.rect.size) - 10
+            empty_dot = pictures.transform_pygame_image(self.empty_orig, (side, side), color=self.color)
+            filled_dot = pictures.transform_pygame_image(self.filled_orig, (side, side), color=self.color)
+            x = (self.rect.width - (side * self.total + border * (self.total - 1))) // 2
+            y = (self.rect.height - side) // 2
+            for i in range(self.total):
+                if i < self.current:
+                    self.image.blit(filled_dot, (x, y))
+                else:
+                    self.image.blit(empty_dot, (x, y))
+                x += (side + border)
+
+
+class BasePygameScene(BaseScene):
+
+    """Base class for Pygame scene. It use dirty sprite mechanism to
+    save CPU usage.
+    """
+
+    BACKGROUND = None
+
+    def __init__(self, name):
+        super(BasePygameScene, self).__init__(name)
+        self.sprites = pygame.sprite.LayeredDirty()
+        self.image = ImageSprite()
+        self.image.visible = 0
+        self.add_sprite(self.image, layer=3)
+        self.text_color = (255, 255, 255)
+        self.arrow_location = BaseScene.ARROW_BOTTOM
+
+        # On Raspberry Pi, the time to update dirty sprites is long (120-180ms
+        # tested), increasing the treshold permits to avoid blitting full screen
+        # at each draw() call.
+        self.sprites.set_timing_threshold(200)
+
+    def add_sprite(self, sprite, outlines=True, layer=0):
+        """Declare a new sprite to draw.
+
+        Sprites are drawn as ordered. Four layers are defined:
+
+            - 0: one background sprite
+            - 1: texts sprites
+            - 2: assets sprites
+            - 3: one speciale image sprite
+            - 4: arrows sprites
+            - 5: outlines sprites
+
+        :param sprite: sprite to add in the draw mechanism
+        :type sprite: object
+        :param outlines: enable outlines
+        :type outlines: bool
+        :param layer: layer indice
+        :type layer: int
+        """
+        if layer == 0 and isinstance(sprite, ArrowSprite):
+            layer = 4
+        elif layer == 0 and isinstance(sprite, (ImageSprite, DotsSprite)):
+            layer = 2
+        elif layer == 0 and isinstance(sprite, TextSprite):
+            layer = 1
+        self.sprites.add(sprite, layer=layer)
+        if outlines:
+            self.sprites.add(OutlinesSprite(sprite), layer=5)
+        return sprite
+
+    @property
+    def background(self):
+        return BasePygameScene.BACKGROUND
+
+    @property
+    def rect(self):
+        return BasePygameScene.BACKGROUND.rect
+
+    def set_background(self, color_or_path, size):
+        """Set background sprite.
+
+        :param color_or_path: color of path to an imgae
+        :type color_or_path: tuple or str
+        :param size: size tuple (width, height) of the image.
+        :type size: tuple
+        """
+        if not BasePygameScene.BACKGROUND:
+            BasePygameScene.BACKGROUND = ImageSprite(size=size)
+        if not self.sprites.has(self.background):
+            self.sprites.add(self.background, layer=0)
+        self.background.set_rect(0, 0, size[0], size[1])
+        self.background.set_skin(color_or_path)
+        self.background.set_crop()
+
+    def set_outlines(self, enable=True):
+        """Draw outlines for each rectangle available for drawing
+        images and texts.
+
+        :param enable: enable / disable outlines
+        :type enable: bool
+        """
+        for sprite in self.sprites.get_sprites_from_layer(5):
+            if enable:
+                sprite.enable()
+            else:
+                sprite.disable()
+
+    def set_image(self, image=None):
+        """Set an image to the main place or hide it.
+
+        :param image: image to set (path, PIL object or pygame object)
+        :type image: str or object
+        """
+        if image:
+            self.image.show()
+            self.image.set_skin(image)
+        else:
+            self.image.hide()
+
+    def set_arrows(self, location, offset):
+        """Set arrows attributes.
+
+        :param location: arrow location: ARROW_HIDDEN, ARROW_BOTTOM, ARROW_TOP, ARROW_TOUCH
+        :type location: str
+        :param offset: x offset from current position to screen outer
+        :type offset: int
+        """
+        self.arrow_location = location
+        for sprite in self.sprites.get_sprites_from_layer(4):
+            sprite.set_location(location)
+            sprite.set_offset(offset)
+
+    def set_text_color(self, color):
+        """Set text font color.
+
+        :param color: RGB color tuple for the texts
+        :type color: tuple
+        """
+        self.text_color = color
+        # Texts
+        for sprite in self.sprites.get_sprites_from_layer(1):
+            sprite.set_color(color)
+
+        # Assets
+        for sprite in self.sprites.get_sprites_from_layer(2):
+            sprite.set_color(color)
+
+        # Arrows
+        for sprite in self.sprites.get_sprites_from_layer(4):
+            sprite.set_color(color)
+
+    def set_print_number(self, current_nbr=None, failure=False):
+        pass
+
+    def update(self, events):
+        """Pygame events processing callback method.
+
+        :param events: list of events to process.
+        :type events: list
+        """
+        for event in events:
+            if event.type == pygame.MOUSEBUTTONDOWN\
+                    and event.button in (1, 2, 3):
+                # Don't consider the mouse wheel (button 4 & 5):
+                sprite = evts.get_top_visible(self.sprites.get_sprites_at(event.pos))
+                if sprite:
+                    sprite.set_pressed(1)
+            elif event.type == pygame.FINGERDOWN:
+                display_size = pygame.display.get_surface().get_size()
+                finger_pos = (event.x * display_size[0], event.y * display_size[1])
+                sprite = evts.get_top_visible(self.sprites.get_sprites_at(finger_pos))
+                if sprite:
+                    sprite.set_pressed(1)
+            elif (event.type == pygame.MOUSEBUTTONUP and event.button in (1, 2, 3))\
+                    or event.type == pygame.FINGERUP:
+                # Don't consider the mouse wheel (button 4 & 5):
+                for sprite in self.sprites.sprites():
+                    if hasattr(sprite, 'set_pressed'):
+                        # Outlines can not be pressed
+                        sprite.set_pressed(0)
+
+        self.sprites.update(events)
+
+    def draw(self, surface, force=False):
+        """Draw the elements on scene.
+
+        This method is optimized to be called at each loop of the
+        main application. It uses DirtySprite to update only parts
+        of the screen that need to be refreshed.
+
+        The first call to this method will setup the "eraser" surface that
+        will be used to redraw dirty parts of the screen.
+
+        The `force` parameter shall be used if the surface has been redrawn:
+        it reset the eraser and redraw all view elements.
+
+        :param surface: surface the slider will be displayed at
+        :type surface: object
+        :param force: force the drawing of the entire surface (time consuming)
+        :type force: bool
+
+        :return: list of updated area
+        :rtype: list
+        """
+        if force:
+            self.sprites.repaint_rect(self.background.rect)
+        return self.sprites.draw(surface)
diff --git a/pibooth/view/pygame/window.py b/pibooth/view/pygame/window.py
new file mode 100644
index 00000000..b870ab03
--- /dev/null
+++ b/pibooth/view/pygame/window.py
@@ -0,0 +1,210 @@
+# -*- coding: utf-8 -*-
+
+"""PygameWindow class to build a Pygame UI.
+"""
+
+import os
+import pygame
+import pygame_vkeyboard as vkb
+
+from pibooth import evts
+from pibooth.utils import LOGGER
+from pibooth.view.base import BaseWindow, BaseScene
+from pibooth.view.pygame import scenes, menu
+
+
+class PygameWindow(BaseWindow):
+
+    """Class to create window using Pygame.
+    """
+
+    def __init__(self, title,
+                 size=(800, 480),
+                 background=(0, 0, 0),
+                 text_color=(255, 255, 255),
+                 arrow_location=BaseScene.ARROW_BOTTOM,
+                 arrow_offset=0,
+                 debug=False):
+        super(PygameWindow, self).__init__(size, background, text_color, arrow_location, arrow_offset, debug)
+
+        # Prepare the pygame module for use
+        if 'SDL_VIDEO_WINDOW_POS' not in os.environ:
+            os.environ['SDL_VIDEO_CENTERED'] = '1'
+        pygame.init()
+
+        # Save the desktop mode, shall be done before `setmode` (SDL 1.2.10, and pygame 1.8.0)
+        info = pygame.display.Info()
+
+        pygame.display.set_caption(title)
+        self.display_size = (info.current_w, info.current_h)
+        self.surface = pygame.display.set_mode(self._size, pygame.RESIZABLE)
+
+        self._keyboard = vkb.VKeyboard(self.surface,
+                                       self._on_keyboard_event,
+                                       vkb.VKeyboardLayout(vkb.VKeyboardLayout.QWERTY),
+                                       renderer=vkb.VKeyboardRenderer.DARK,
+                                       show_text=True,
+                                       joystick_navigation=True)
+        self._keyboard.disable()
+        self._menu = None
+        self._force_redraw = False
+
+    def set_scene(self, name):
+        super(PygameWindow, self).set_scene(name)
+        self._keyboard.disable()
+        self._force_redraw = True
+
+    def set_menu(self, app, cfg):
+        """Set the menu.
+        """
+        return
+        self._menu = menu.PygameMenu(app, cfg, self._on_menu_event)
+        self._menu.disable()
+
+    def _create_scene(self, name):
+        """Create scene instance."""
+        return scenes.get_scene(name)
+
+    def _on_keyboard_event(self, text):
+        print(text)
+
+    def _on_menu_event(self, text):
+        print(text)
+
+    def get_rect(self, absolute=False):
+        """Return a Rect object (as defined in pygame) for this window.
+
+        :param absolute: absolute position considering the window centered on screen
+        :type absolute: bool
+        """
+        if absolute:
+            if 'SDL_VIDEO_WINDOW_POS' in os.environ:
+                posx, posy = [int(v) for v in os.environ['SDL_VIDEO_WINDOW_POS'].split(',')]
+                return self.surface.get_rect(x=posx, y=posy)
+            else:
+                return self.surface.get_rect(center=(self.display_size[0] / 2, self.display_size[1] / 2))
+        return self.surface.get_rect()
+
+    def toggle_fullscreen(self):
+        """Set window to full screen or initial size.
+        """
+        if self.is_fullscreen:
+            self.is_fullscreen = False  # Set before resize
+            pygame.mouse.set_cursor(pygame.SYSTEM_CURSOR_ARROW)
+            self.surface = pygame.display.set_mode(self._size, pygame.RESIZABLE)
+        else:
+            self.is_fullscreen = True  # Set before resize
+            # Make an invisible cursor (don't use pygame.mouse.set_visible(False) because
+            # the mouse event will always return the window bottom-right coordinate)
+            pygame.mouse.set_cursor((8, 8), (0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0))
+            self.surface = pygame.display.set_mode(self.display_size, pygame.FULLSCREEN)
+
+    def toggle_menu(self):
+        """Show/hide settings menu.
+        """
+        super(PygameWindow, self).toggle_menu()
+        if self._menu:
+            if self.is_menu_shown:
+                self._menu.enable()
+            else:
+                self._menu.disable()
+        else:
+            LOGGER.debug("[ESC] pressed. No menu configured -> exit")
+            exit(0)
+
+    def update(self, events):
+        """Update sprites according to Pygame events.
+
+        :param events: list of events to process.
+        :type events: list
+        """
+        for event in events:
+            if event.type == pygame.VIDEORESIZE and not self.is_fullscreen:
+                # Manual resizing
+                self.surface = pygame.display.set_mode(event.size, pygame.RESIZABLE)
+                self.resize(event.size)
+                if self._menu and self._menu.is_enabled():
+                    self._menu.resize(event.size)
+
+            elif evts.is_fullscreen_event(event):
+                self.toggle_fullscreen()
+                self.resize(self.get_rect().size)
+                if self._menu and self._menu.is_enabled():
+                    self._menu.resize(self.get_rect().size)
+
+            elif evts.is_button_print_event(event):
+                # Convert HW button events to keyboard events for menu
+                event = evts.create_click_event()
+                LOGGER.debug("Generate MENU-APPLY event for menu")
+
+            elif self._keyboard.is_enabled() and \
+                    (event.type == pygame.MOUSEBUTTONDOWN and event.button in (1, 2, 3) or event.type == pygame.FINGERDOWN)\
+                    and not self._keyboard.get_rect().collidepoint(evts.get_event_pos(self.display_size, event)):
+                self._keyboard.disable()
+
+            elif self._keyboard.is_enabled() and event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
+                self._keyboard.disable()
+
+            # Convert GUI events to pibooth events (plugins are based on them)
+            elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
+                self.toggle_menu()
+                evts.post(evts.EVT_PIBOOTH_BTN_SETTINGS, is_shown=self.is_menu_shown)
+
+            elif evts.is_fingers_event(event, 4):
+                self.toggle_menu()
+                evts.post(evts.EVT_PIBOOTH_BTN_SETTINGS, is_shown=self.is_menu_shown)
+
+            elif event.type == pygame.KEYDOWN and event.key == pygame.K_c:
+                evts.post_button_capture_event()
+
+            elif event.type == pygame.KEYDOWN and event.key == pygame.K_p:
+                evts.post_button_print_event()
+
+        self.scene.update(events)
+
+        if self._keyboard.is_enabled():
+            self._keyboard.update(events)
+        elif self._menu and self._menu.is_enabled():
+            self._menu.update(events)
+
+    def draw(self):
+        """Draw all Sprites on surface and return updated Pygame rects.
+        """
+        rects = self.scene.draw(self.surface, self._force_redraw)
+
+        if self._keyboard.is_enabled():
+            rects += self._keyboard.draw(self.surface, self._force_redraw)
+
+        self._force_redraw = False
+        return rects
+
+    def eventloop(self, app_update):
+        """Main GUI events loop (blocking).
+        """
+        fps = 25
+        clock = pygame.time.Clock()
+
+        while True:
+            events = list(pygame.event.get())
+
+            # 0. Check if exit request
+            for event in events:
+                if event.type == pygame.QUIT:
+                    return
+
+            # 1. Update application and plugins according to user events,
+            #    it may update view elements internal variables
+            app_update(events)
+
+            # 2. Update view elements according to user events and re-create
+            #    images for view elements which have changed
+            self.update(events)
+
+            # 3. Draw on buffer view elements which have changed
+            rects = self.draw()
+
+            # 4. Update dirty rects on screen (the most time-consuming action)
+            pygame.display.update(rects)
+
+            # 5. Ensure the program will never run at more than <fps> frames per second
+            clock.tick(fps)
diff --git a/pibooth/view/window.py b/pibooth/view/window.py
deleted file mode 100644
index 1f4356cb..00000000
--- a/pibooth/view/window.py
+++ /dev/null
@@ -1,362 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""Pibooth view management.
-"""
-
-import os
-import pygame
-from pygame import gfxdraw
-from PIL import Image
-from pibooth import pictures, fonts
-from pibooth.view import background
-from pibooth.utils import LOGGER
-from pibooth.pictures import sizing
-
-
-class PiWindow(object):
-
-    """Class to handle the window.
-    The following attributes are available for use in plugins:
-
-    :attr surface: surface on which sprites are displayed
-    :type surface: :py:class:`pygame.Surface`
-    :attr is_fullscreen: set to True if the window is display in full screen
-    :type is_fullscreen: bool
-    :attr display_size: tuple (width, height) represneting the size of the screen
-    :type display_size: tuple
-    """
-
-    CENTER = 'center'
-    RIGHT = 'right'
-    LEFT = 'left'
-    FULLSCREEN = 'fullscreen'
-
-    def __init__(self, title,
-                 size=(800, 480),
-                 color=(0, 0, 0),
-                 text_color=(255, 255, 255),
-                 arrow_location=background.ARROW_BOTTOM,
-                 arrow_offset=0,
-                 debug=False):
-        self.__size = size
-        self.debug = debug
-        self.bg_color = color
-        self.text_color = text_color
-        self.arrow_location = arrow_location
-        self.arrow_offset = arrow_offset
-
-        # Prepare the pygame module for use
-        os.environ['SDL_VIDEO_CENTERED'] = '1'
-        pygame.init()
-
-        # Save the desktop mode, shall be done before `setmode` (SDL 1.2.10, and pygame 1.8.0)
-        info = pygame.display.Info()
-
-        pygame.display.set_caption(title)
-        self.is_fullscreen = False
-        self.display_size = (info.current_w, info.current_h)
-        self.surface = pygame.display.set_mode(self.__size, pygame.RESIZABLE)
-
-        self._buffered_images = {}
-        self._current_background = None
-        self._current_foreground = None
-        self._print_number = 0
-        self._print_failure = False
-        self._capture_number = (0, 4)  # (current, max)
-        self._is_flash_on = False
-
-        self._pos_map = {self.CENTER: self._center_pos,
-                         self.RIGHT: self._right_pos,
-                         self.LEFT: self._left_pos,
-                         self.FULLSCREEN: self._center_pos}
-
-        # Don't use pygame.mouse.get_cursor() because will be removed in pygame2
-        self._cursor = ((16, 16), (0, 0),
-                        (0, 0, 64, 0, 96, 0, 112, 0, 120, 0, 124, 0, 126, 0, 127, 0,
-                         127, 128, 124, 0, 108, 0, 70, 0, 6, 0, 3, 0, 3, 0, 0, 0),
-                        (192, 0, 224, 0, 240, 0, 248, 0, 252, 0, 254, 0, 255, 0, 255,
-                         128, 255, 192, 255, 224, 254, 0, 239, 0, 207, 0, 135, 128, 7, 128, 3, 0))
-
-    def _update_foreground(self, pil_image, pos=CENTER, resize=True):
-        """Show a PIL image on the foreground.
-        Only one is bufferized to avoid memory leak.
-        """
-        image_name = id(pil_image)
-
-        if pos == self.FULLSCREEN:
-            image_size_max = (self.surface.get_size()[0] * 0.9, self.surface.get_size()[1] * 0.9)
-        else:
-            image_size_max = (self.surface.get_size()[0] * 0.48, self.surface.get_size()[1])
-
-        buff_size, buff_image = self._buffered_images.get(image_name, (None, None))
-        if buff_image and image_size_max == buff_size:
-            image = buff_image
-        else:
-            if resize:
-                image = pil_image.resize(sizing.new_size_keep_aspect_ratio(
-                    pil_image.size, image_size_max), Image.ANTIALIAS)
-            else:
-                image = pil_image
-            image = pygame.image.frombuffer(image.tobytes(), image.size, image.mode)
-            if self._current_foreground:
-                self._buffered_images.pop(id(self._current_foreground[0]), None)
-            LOGGER.debug("Add to buffer the image '%s'", image_name)
-            self._buffered_images[image_name] = (image_size_max, image)
-
-        self._current_foreground = (pil_image, pos, resize)
-
-        if self.debug and resize:
-            # Build rectangle around picture area for debuging purpose
-            outlines = pygame.Surface(image_size_max, pygame.SRCALPHA, 32)
-            pygame.draw.rect(outlines, pygame.Color(255, 0, 0), outlines.get_rect(), 2)
-            self.surface.blit(outlines, self._pos_map[pos](outlines))
-
-        return self.surface.blit(image, self._pos_map[pos](image))
-
-    def _update_background(self, bkgd):
-        """Show image on the background.
-        """
-        self._current_background = self._buffered_images.setdefault(str(bkgd), bkgd)
-        self._current_background.set_color(self.bg_color)
-        self._current_background.set_outlines(self.debug)
-        self._current_background.set_text_color(self.text_color)
-        self._current_background.resize(self.surface)
-        self._current_background.paint(self.surface)
-        self._update_capture_number()
-        self._update_print_number()
-
-    def _update_capture_number(self):
-        """Update the captures counter displayed.
-        """
-        if not self._capture_number[0]:
-            return  # Dont show counter: no picture taken
-
-        center = self.surface.get_rect().center
-        radius = 10
-        border = 20
-        x = center[0] - (2 * radius * self._capture_number[1] + border * (self._capture_number[1] - 1)) // 2
-        y = self.surface.get_size()[1] - radius - border
-        for nbr in range(self._capture_number[1]):
-            gfxdraw.aacircle(self.surface, x, y, radius, self.text_color)
-            if self._capture_number[0] > nbr:
-                # Because anti-aliased filled circle doesn't exist
-                gfxdraw.aacircle(self.surface, x, y, radius - 3, self.text_color)
-                gfxdraw.filled_circle(self.surface, x, y, radius - 3, self.text_color)
-            x += (2 * radius + border)
-
-    def _update_print_number(self):
-        """Update the number of files in the printer queue.
-        """
-        if not self._print_number and not self._print_failure:
-            return  # Dont show counter: no file in queue, no failure
-
-        smaller = self.surface.get_size()[1] if self.surface.get_size(
-        )[1] < self.surface.get_size()[0] else self.surface.get_size()[0]
-        side = int(smaller * 0.05)  # 5% of the window
-
-        if side > 0:
-            if self._print_failure:
-                image = pictures.get_pygame_image('printer_failure.png', (side, side), color=self.text_color)
-            else:
-                image = pictures.get_pygame_image('printer.png', (side, side), color=self.text_color)
-            y = self.surface.get_rect().height - image.get_rect().height - 10
-            self.surface.blit(image, (10, y))
-            font = pygame.font.Font(fonts.CURRENT, side)
-            label = font.render(str(self._print_number), True, self.text_color)
-            self.surface.blit(label, (side + 20, y))
-
-    def _center_pos(self, image):
-        """
-        Return the position of the given image to be centered on window.
-        """
-        pos = self.surface.get_rect().center
-        return image.get_rect(center=pos) if image else pos
-
-    def _left_pos(self, image):
-        """
-        Return the position of the given image to be put on the left of the screen
-        """
-        pos = (self.surface.get_rect().centerx // 2, self.surface.get_rect().centery)
-        return image.get_rect(center=pos) if image else pos
-
-    def _right_pos(self, image):
-        """
-        Return the position of the given image to be put on the right of the screen
-        """
-        pos = (self.surface.get_rect().centerx + self.surface.get_rect().centerx // 2, self.surface.get_rect().centery)
-        return image.get_rect(center=pos) if image else pos
-
-    def get_rect(self, absolute=False):
-        """Return a Rect object (as defined in pygame) for this window.
-
-        :param absolute: absolute position considering the window centered on screen
-        :type absolute: bool
-        """
-        if absolute:
-            return self.surface.get_rect(center=(self.display_size[0] / 2, self.display_size[1] / 2))
-        return self.surface.get_rect()
-
-    def get_image(self):
-        """Return the currently displayed foreground image.
-        """
-        if self._current_foreground:
-            return self._current_foreground[0]
-        return None
-
-    def resize(self, size):
-        """Resize the window keeping aspect ratio.
-        """
-        if not self.is_fullscreen:
-            self.__size = size  # Manual resizing
-            self.surface = pygame.display.set_mode(self.__size, pygame.RESIZABLE)
-        self.update()
-
-    def update(self):
-        """Repaint the window with currently displayed images.
-        """
-        if self._current_background:
-            self._update_background(self._current_background)
-        else:
-            self._update_capture_number()
-            self._update_print_number()
-        if self._current_foreground:
-            self._update_foreground(*self._current_foreground)
-
-    def show_oops(self):
-        """Show failure view in case of exception.
-        """
-        self._capture_number = (0, self._capture_number[1])
-        self._update_background(background.OopsBackground())
-
-    def show_intro(self, pil_image=None, with_print=True):
-        """Show introduction view.
-        """
-        self._capture_number = (0, self._capture_number[1])
-        if with_print and pil_image:
-            self._update_background(background.IntroWithPrintBackground(self.arrow_location, self.arrow_offset))
-        else:
-            self._update_background(background.IntroBackground(self.arrow_location, self.arrow_offset))
-
-        if pil_image:
-            self._update_foreground(pil_image, self.RIGHT)
-        elif self._current_foreground:
-            self._buffered_images.pop(id(self._current_foreground[0]), None)
-            self._current_foreground = None
-
-    def show_choice(self, choices, selected=None):
-        """Show the choice view.
-        """
-        self._capture_number = (0, self._capture_number[1])
-        if not selected:
-            self._update_background(background.ChooseBackground(choices, self.arrow_location, self.arrow_offset))
-        else:
-            self._update_background(background.ChosenBackground(choices, selected))
-
-    def show_image(self, pil_image=None, pos=CENTER):
-        """Show PIL image as it (no resize).
-        """
-        if not pil_image:
-            # Clear the currently displayed image
-            if self._current_foreground:
-                _, image = self._buffered_images.pop(id(self._current_foreground[0]))
-                _, pos, _ = self._current_foreground
-                self._current_foreground = None
-                image.fill((0, 0, 0))
-                return self.surface.blit(image, self._pos_map[pos](image))
-        else:
-            return self._update_foreground(pil_image, pos, False)
-
-    def show_work_in_progress(self):
-        """Show wait view.
-        """
-        self._capture_number = (0, self._capture_number[1])
-        self._update_background(background.ProcessingBackground())
-
-    def show_print(self, pil_image=None):
-        """Show print view (image resized on the left).
-        """
-        self._capture_number = (0, self._capture_number[1])
-        self._update_background(background.PrintBackground(self.arrow_location,
-                                                           self.arrow_offset))
-        if pil_image:
-            self._update_foreground(pil_image, self.LEFT)
-
-    def show_finished(self, pil_image=None):
-        """Show finished view (image resized fullscreen).
-        """
-        self._capture_number = (0, self._capture_number[1])
-        if pil_image:
-            bg = background.FinishedWithImageBackground(pil_image.size)
-            if self._buffered_images.get(str(bg), bg).foreground_size != pil_image.size:
-                self._buffered_images.pop(str(bg))  # Drop cache, foreground size ratio has changed
-            self._update_background(background.FinishedWithImageBackground(pil_image.size))
-            self._update_foreground(pil_image, self.FULLSCREEN)
-        else:
-            self._update_background(background.FinishedBackground())
-
-    def set_capture_number(self, current_nbr, total_nbr):
-        """Set the current number of captures taken.
-        """
-        if total_nbr < 1:
-            raise ValueError("Total number of captures shall be greater than 0")
-
-        if self._capture_number != (current_nbr, total_nbr):
-            self._capture_number = (current_nbr, total_nbr)
-            self._update_background(background.CaptureBackground())
-            if self._current_foreground:
-                self._update_foreground(*self._current_foreground)
-
-    def set_print_number(self, current_nbr=None, failure=None):
-        """Set the current number of tasks in the printer queue.
-        """
-        update = False
-
-        if current_nbr is not None and self._print_number != current_nbr:
-            self._print_number = current_nbr
-            update = True
-
-        if failure is not None and self._print_failure != failure:
-            self._print_failure = failure
-            update = True
-
-        if update:
-            self._update_background(self._current_background)
-            if self._current_foreground:
-                self._update_foreground(*self._current_foreground)
-            pygame.display.update()
-
-    def toggle_flash(self):
-        """Update background in white.
-        """
-        if not self._is_flash_on:
-            self.surface.fill((255, 255, 255))
-            if self._current_foreground:
-                # Flash only the background, keep foreground at the top
-                self._update_foreground(*self._current_foreground)
-        else:
-            self.update()
-        self._is_flash_on = not self._is_flash_on
-
-    def toggle_fullscreen(self):
-        """Set window to full screen or initial size.
-        """
-        if self.is_fullscreen:
-            self.is_fullscreen = False  # Set before resize
-            pygame.mouse.set_cursor(*self._cursor)
-            self.surface = pygame.display.set_mode(self.__size, pygame.RESIZABLE)
-        else:
-            self.is_fullscreen = True  # Set before resize
-            # Make an invisible cursor (don't use pygame.mouse.set_visible(False) because
-            # the mouse event will always return the window bottom-right coordinate)
-            pygame.mouse.set_cursor((8, 8), (0, 0), (0, 0, 0, 0, 0, 0, 0, 0), (0, 0, 0, 0, 0, 0, 0, 0))
-            self.surface = pygame.display.set_mode(self.display_size, pygame.FULLSCREEN)
-
-        self.update()
-
-    def drop_cache(self):
-        """Drop all cached background and foreground to force
-        refreshing the view.
-        """
-        self._current_background = None
-        self._current_foreground = None
-        self._buffered_images = {}
diff --git a/setup.py b/setup.py
index 50f66b20..f2ce58e8 100644
--- a/setup.py
+++ b/setup.py
@@ -64,29 +64,30 @@ def main():
         include_package_data=True,
         python_requires=">=3.6",
         install_requires=[
-            'picamera>=1.13 ; platform_machine>="armv0l" and platform_machine<="armv9l"',
-            'Pillow>=8.3.1',
-            'pygame>=1.9.6',
-            'pygame-menu==4.0.7',
+            'picamera2>=0.3.2 ; platform_machine>="armv0l" and platform_machine<="armv9l"',
+            'Pillow>=9.2.0',
+            'pygame>=2.1.2',
+            'pygame-menu==4.2.8',
             'pygame-vkeyboard>=2.0.8',
-            'psutil>=5.5.1',
-            'pluggy>=0.13.1',
-            'gpiozero>=1.5.1',
+            'pygame-imslider>=1.0.1',
+            'psutil>=5.9.1',
+            'pluggy>=1.0.0',
+            'gpiozero>=1.6.2',
             # RPi.GPIO backend for gpiozero (not always installed by default)
-            'RPi.GPIO>=0.7.0 ; platform_machine>="armv0l" and platform_machine<="armv9l"'
+            'RPi.GPIO>=0.7.1 ; platform_machine>="armv0l" and platform_machine<="armv9l"'
         ],
         extras_require={
-            'dslr': ['gphoto2>=2.0.0'],
-            'printer': ['pycups>=1.9.73', 'pycups-notify>=0.0.4'],
+            'dslr': ['gphoto2>=2.3.4'],
+            'printer': ['pycups>=2.0.1', 'pycups-notify>=0.0.6'],
             'doc': docs_require
         },
         zip_safe=False,  # Don't install the lib as an .egg zipfile
-        entry_points={'console_scripts': ["pibooth = pibooth.booth:main",
+        entry_points={'console_scripts': ["pibooth = pibooth.__main__:main",
                                           "pibooth-count = pibooth.scripts.count:main",
                                           "pibooth-diag = pibooth.scripts.diagnostic:main",
                                           "pibooth-fonts = pibooth.scripts.fonts:main",
                                           "pibooth-regen = pibooth.scripts.regenerate:main",
-                                          "pibooth-printcfg = pibooth.scripts.printer:main"]},
+                                          "pibooth-printer = pibooth.scripts.printer:main"]},
     )
 
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index b8cf93b8..05600929 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,10 +3,13 @@
 
 import os
 import pytest
+import pygame
 from PIL import Image
 from pibooth import language
+from pibooth.tasks import AsyncTasksPool
 from pibooth.counters import Counters
-from pibooth.config.parser import PiConfigParser
+from pibooth.config.parser import PiboothConfigParser
+from pibooth.view.pygame.scenes import get_scene
 from pibooth.camera import get_rpi_camera_proxy, get_gp_camera_proxy, get_cv_camera_proxy
 from pibooth.camera import RpiCamera, GpCamera, CvCamera, HybridRpiCamera, HybridCvCamera
 
@@ -62,7 +65,7 @@ def cfg_path():
 
 @pytest.fixture(scope='session')
 def cfg(cfg_path):
-    return PiConfigParser(cfg_path, None)
+    return PiboothConfigParser(cfg_path, None)
 
 
 @pytest.fixture
@@ -70,6 +73,11 @@ def counters(tmpdir):
     return Counters(str(tmpdir.join('data.pickle')), nbr_printed=0)
 
 
+@pytest.fixture(scope='session')
+def init_tasks():
+    return AsyncTasksPool()
+
+
 @pytest.fixture(scope='session')
 def proxy_rpi():
     return get_rpi_camera_proxy()
@@ -123,3 +131,47 @@ def camera_gp(proxy_gp):
     cam.initialize(ISO, RESOLUTION, delete_internal_memory=True)
     yield cam
     cam.quit()
+
+
+@pytest.fixture(scope='session')
+def pygame_loop():
+
+    pygame.init()
+    pygame.display.set_caption("Hit [ESC] to end the test")
+    screen = pygame.display.set_mode((400, 400), pygame.RESIZABLE)
+    screen.fill((0, 0, 0))
+    clock = pygame.time.Clock()
+
+    def loop(event_handler):
+        while True:
+            events = pygame.event.get()
+
+            for event in events:
+                if event.type == pygame.QUIT or\
+                        (event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE):
+                    screen.fill((0, 0, 0))
+                    pygame.display.update()
+                    return
+
+            event_handler(screen, events)
+            pygame.display.update()
+            clock.tick(5)
+
+    return loop
+
+
+@pytest.fixture(scope='session')
+def scene_builder():
+
+    def create(name):
+        scene = get_scene(name)
+        scene.set_outlines(True)
+        scene.set_background((0, 0, 0), (400, 400))
+        scene.set_text_color((255, 255, 255))
+        scene.set_arrows(scene.ARROW_BOTTOM, 0)
+        scene.set_print_number(2, False)
+        scene.need_resize = True
+        scene.resize((400, 400))
+        return scene
+
+    return create
diff --git a/tests/dslr_diag/Canon SX 720HS.txt b/tests/dslr_diag/Canon SX 720HS.txt
new file mode 100644
index 00000000..861d24d6
--- /dev/null
+++ b/tests/dslr_diag/Canon SX 720HS.txt
@@ -0,0 +1,543 @@
+Pibooth version installed: 2.0.6
+Installed plugins: 
+GPhoto2 version installed: 2.5.27
+  - standard camlib set (ax203 canon digigr8 dimagev directory docupen jl2005a jl2005c kodak_dc240 lumix mars pentax ptp2 ricoh_g3 sierra sonix sq905 st2205 topfield tp6801)
+  - gcc (C compiler used)
+  - ltdl (for portable loading of camlibs)
+  - EXIF (for special handling of EXIF files)
+Listing all connected DSLR camera
+00 : addr-> usb:001,010  name-> Canon SX 720HS
+
+================================================================================
+Stating diagnostic of connected DSLR camera
+* Preview compatible: True
+* Capture compatible: True
+/actions/syncdatetimeutc
+  Label       : Synchronize camera date and time with PC
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Toggle button (or check box)
+  Current     : 0
+  Choices     : [0, 1]
+/actions/uilock
+  Label       : UI Lock
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Toggle button (or check box)
+  Current     : 2
+  Choices     : [0, 1]
+/actions/autofocusdrive
+  Label       : Drive Canon DSLR Autofocus
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Toggle button (or check box)
+  Current     : 0
+  Choices     : [0, 1]
+/actions/cancelautofocus
+  Label       : Cancel Canon DSLR Autofocus
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Toggle button (or check box)
+  Current     : 0
+  Choices     : [0, 1]
+/actions/viewfinder
+  Label       : Canon EOS Viewfinder
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Toggle button (or check box)
+  Current     : 0
+  Choices     : [0, 1]
+/actions/eosremoterelease
+  Label       : Canon EOS Remote Release
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : None
+  Choices     : ['None', 'Press Half', 'Press Full', 'Release Half', 'Release Full', 'Immediate', 'Press 1', 'Press 2', 'Press 3', 'Release 1', 'Release 2', 'Release 3']
+/actions/eosmoviemode
+  Label       : Movie Mode
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Toggle button (or check box)
+  Current     : 2
+  Choices     : [0, 1]
+/actions/opcode
+  Label       : PTP Opcode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 0x1001,0xparam1,0xparam2
+  Choices     : n/a
+/settings/datetimeutc
+  Label       : Camera Date and Time
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Date entering
+  Current     : 1661631260
+  Choices     : n/a
+/settings/datetime
+  Label       : Camera Date and Time
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Date entering
+  Current     : 1661631263
+  Choices     : n/a
+/settings/output
+  Label       : Camera Output
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : MOBILE2
+  Choices     : ['Off', 'MOBILE2']
+/settings/evfmode
+  Label       : EVF Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : 1
+  Choices     : ['1']
+/settings/ownername
+  Label       : Owner Name
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 
+  Choices     : n/a
+/settings/ownername
+  Label       : Owner Name
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 
+  Choices     : n/a
+/settings/artist
+  Label       : Artist
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 
+  Choices     : n/a
+/settings/copyright
+  Label       : Copyright
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 
+  Choices     : n/a
+/settings/depthoffield
+  Label       : Depth of Field
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 0
+  Choices     : n/a
+/settings/capturetarget
+  Label       : Capture Target
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Memory card
+  Choices     : ['Internal RAM', 'Memory card']
+/settings/capture
+  Label       : Capture
+  Readonly    : no
+  Data type   : <class 'int'>
+  Widget type : Toggle button (or check box)
+  Current     : 0
+  Choices     : [0, 1]
+/settings/remotemode
+  Label       : Remote Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 0
+  Choices     : n/a
+/settings/eventmode
+  Label       : Event Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 0
+  Choices     : n/a
+/status/serialnumber
+  Label       : Serial Number
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : FD75F358C7497BBD589D1BBE71CD7234
+  Choices     : n/a
+/status/manufacturer
+  Label       : Camera Manufacturer
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : Canon Inc.
+  Choices     : n/a
+/status/cameramodel
+  Label       : Camera Model
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : Canon PowerShot SX720 HS
+  Choices     : n/a
+/status/deviceversion
+  Label       : Device Version
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 1-15.0.1.0
+  Choices     : n/a
+/status/vendorextension
+  Label       : Vendor Extension
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 
+  Choices     : n/a
+/status/model
+  Label       : Camera Model
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : Canon PowerShot SX720 HS
+  Choices     : n/a
+/status/model
+  Label       : Camera Model
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 67436544
+  Choices     : n/a
+/status/firmwareversion
+  Label       : Firmware Version
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 1.0.1.0
+  Choices     : n/a
+/status/
+  Label       : Battery Level
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 2%
+  Choices     : n/a
+/status/batterylevel
+  Label       : Battery Level
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 50%
+  Choices     : n/a
+/status/eosserialnumber
+  Label       : Serial Number
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 313054003087
+  Choices     : n/a
+/status/availableshots
+  Label       : Available Shots
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 4220
+  Choices     : n/a
+/imgsettings/imageformat
+  Label       : Image Format
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Large Fine JPEG
+  Choices     : ['Large Fine JPEG']
+/imgsettings/iso
+  Label       : ISO Speed
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Auto
+  Choices     : ['Auto', '80', '100', '200', '400', '800', '1600', '3200']
+/imgsettings/whitebalance
+  Label       : WhiteBalance
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Auto
+  Choices     : ['Auto']
+/capturesettings/zoom
+  Label       : Zoom
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : 0
+  Choices     : ['127']
+/capturesettings/exposurecompensation
+  Label       : Exposure Compensation
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : 0
+  Choices     : ['-2', '-1.6', '-1.3', '-1', '-0.6', '-0.3', '0', '0.3', '0.6', '1', '1.3', '1.6', '2']
+/capturesettings/focusmode
+  Label       : Focus Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : One Shot
+  Choices     : ['One Shot']
+/capturesettings/continuousaf
+  Label       : Continuous AF
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : On
+  Choices     : ['Off', 'On']
+/capturesettings/aspectratio
+  Label       : Aspect Ratio
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : 4:3
+  Choices     : ['4:3']
+/capturesettings/storageid
+  Label       : Storage Device
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 00010001
+  Choices     : n/a
+/capturesettings/autoexposuremode
+  Label       : Canon Auto Exposure Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : P
+  Choices     : ['P', 'TV', 'AV', 'Manual', 'Bulb', 'A_DEP', 'DEP', 'Custom', 'Lock', 'Green', 'Night Portrait', 'Sports', 'Portrait', 'Landscape', 'Closeup', 'Flash Off', 'Auto', 'Handheld Night Scene', 'HDR Backlight Control', 'Food', 'Grainy B/W', 'Soft focus', 'Toy camera effect', 'Fish-eye effect', 'Water painting effect', 'Miniature effect', 'HDR art standard', 'HDR art vivid', 'HDR art bold', 'HDR art embossed', 'Panning', 'Self Portrait', 'Hybrid Auto', 'Smooth skin', 'Fv']
+/capturesettings/autoexposuremodedial
+  Label       : Canon Auto Exposure Mode Dial
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Unknown value 022b
+  Choices     : ['P', 'TV', 'AV', 'Manual', 'Bulb', 'A_DEP', 'DEP', 'Custom', 'Lock', 'Green', 'Night Portrait', 'Sports', 'Portrait', 'Landscape', 'Closeup', 'Flash Off', 'Auto', 'Handheld Night Scene', 'HDR Backlight Control', 'Food', 'Grainy B/W', 'Soft focus', 'Toy camera effect', 'Fish-eye effect', 'Water painting effect', 'Miniature effect', 'HDR art standard', 'HDR art vivid', 'HDR art bold', 'HDR art embossed', 'Panning', 'Self Portrait', 'Hybrid Auto', 'Smooth skin', 'Fv', 'Unknown value 022b']
+/capturesettings/drivemode
+  Label       : Drive Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Single
+  Choices     : ['Single', 'Continuous', 'Timer 10 sec', 'Timer 2 sec']
+/capturesettings/aperture
+  Label       : Aperture
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : implicit auto
+  Choices     : ['implicit auto']
+/capturesettings/shutterspeed
+  Label       : Shutter Speed
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : auto
+  Choices     : ['auto']
+/capturesettings/meteringmode
+  Label       : Metering Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Evaluative
+  Choices     : ['Evaluative']
+/capturesettings/liveviewsize
+  Label       : Live View Size
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Radio button
+  Current     : Small
+  Choices     : ['Small']
+/other/d045
+  Label       : Event Emulate Mode
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Menu widget (same as Radio)
+  Current     : 9
+  Choices     : ['1', '2', '3', '4', '5', '6', '7']
+/other/d04a
+  Label       : PTP Property 0xd04a
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Menu widget (same as Radio)
+  Current     : 0
+  Choices     : ['0', '1', '2', '3']
+/other/d02e
+  Label       : Size of Output Data from Camera
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 524288
+  Choices     : n/a
+/other/d02f
+  Label       : Size of Input Data to Camera
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 524288
+  Choices     : n/a
+/other/5001
+  Label       : Battery Level
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Menu widget (same as Radio)
+  Current     : 2
+  Choices     : ['0', '1', '2', '3']
+/other/d002
+  Label       : Battery Type
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Menu widget (same as Radio)
+  Current     : 0
+  Choices     : ['0', '1', '2', '3', '4', '5']
+/other/d003
+  Label       : Battery Mode
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Menu widget (same as Radio)
+  Current     : 4
+  Choices     : ['0', '1', '2', '3']
+/other/d034
+  Label       : UNIX Time
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 1661638463
+  Choices     : n/a
+/other/d047
+  Label       : Type of Slideshow
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 0
+  Choices     : n/a
+/other/d046
+  Label       : DPOF Version
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 257
+  Choices     : n/a
+/other/d030
+  Label       : Remote API Version
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 256
+  Choices     : n/a
+/other/d049
+  Label       : Model ID
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 67436544
+  Choices     : n/a
+/other/d032
+  Label       : Camera Model
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : Canon PowerShot SX720 HS
+  Choices     : n/a
+/other/d033
+  Label       : Camera Owner
+  Readonly    : no
+  Data type   : <class 'NoneType'>
+  Widget type : Text
+  Current     : None
+  Choices     : n/a
+/other/d031
+  Label       : Firmware Version
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 16777472
+  Choices     : n/a
+/other/d050
+  Label       : PTP Property 0xd050
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 0
+  Choices     : n/a
+/other/d052
+  Label       : PTP Property 0xd052
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 0
+  Choices     : n/a
+/other/d057
+  Label       : PTP Property 0xd057
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 313054003087
+  Choices     : n/a
+/other/d402
+  Label       : PTP Property 0xd402
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : Canon PowerShot SX720 HS
+  Choices     : n/a
+/other/d406
+  Label       : PTP Property 0xd406
+  Readonly    : no
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : Windows
+  Choices     : n/a
+/other/d407
+  Label       : PTP Property 0xd407
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 1
+  Choices     : n/a
+/other/d303
+  Label       : PTP Property 0xd303
+  Readonly    : yes
+  Data type   : <class 'str'>
+  Widget type : Text
+  Current     : 1
+  Choices     : n/a
+
+================================================================================
+Testing commands used by pibooth
+Setting option imgsettings/iso="100"
+Setting option settings/capturetarget="Memory card"
+Getting option actions/viewfinder=0
+Setting option actions/viewfinder="1"
+Take capture preview
+Setting option actions/viewfinder="0"
+Gphoto2: b'gp_libusb1_read [libusb1.c:614]': b"'libusb_bulk_transfer (port->pl->dh, port->settings.usb.inep, (unsigned char*)bytes, size, &curread, port->timeout)' failed: Operation timed out (-7)"
+Gphoto2: b'gp_port_read [gphoto2-port.c:440]': b'Reading 1024 = 0x400 bytes from port failed: Timeout reading from or writing to the port (-10)'
+Gphoto2: b'ptp_usb_getresp [usb.c:515]': b'PTP_OC 0x9110 receiving resp failed: PTP I/O Error (0x02ff)'
+Gphoto2: b'_put_Canon_EOS_ViewFinder [config.c:7604]': b"'ptp_canon_eos_setdevicepropvalue (params, PTP_DPC_CANON_EOS_EVFOutputDevice, &xval, PTP_DTC_UINT16)' failed: ptp2_eos_viewfinder enable: failed to set evf outputmode to 0 (0x02ff: PTP I/O Error)"
+Gphoto2: b'gp_camera_set_config [gphoto2-camera.c:1114]': b"'camera->functions->set_config (camera, window, context)' failed: -7"
+   -> unsupported setting actions/viewfinder=0 (nothing configured on DSLR)
+Take a capture
+Gphoto2: b'gp_libusb1_read [libusb1.c:614]': b"'libusb_bulk_transfer (port->pl->dh, port->settings.usb.inep, (unsigned char*)bytes, size, &curread, port->timeout)' failed: Operation timed out (-7)"
+Gphoto2: b'gp_port_read [gphoto2-port.c:440]': b'Reading 1024 = 0x400 bytes from port failed: Timeout reading from or writing to the port (-10)'
+Gphoto2: b'ptp_usb_getdata [usb.c:480]': b'PTP_OC 0x9116 receiving data failed: PTP Timeout (0x02fa)'
+Gphoto2: b'gp_libusb1_read [libusb1.c:614]': b"'libusb_bulk_transfer (port->pl->dh, port->settings.usb.inep, (unsigned char*)bytes, size, &curread, port->timeout)' failed: Operation timed out (-7)"
+Gphoto2: b'gp_port_read [gphoto2-port.c:440]': b'Reading 1024 = 0x400 bytes from port failed: Timeout reading from or writing to the port (-10)'
+Gphoto2: b'ptp_usb_getresp [usb.c:515]': b'PTP_OC 0x9128 receiving resp failed: PTP I/O Error (0x02ff)'
+Gphoto2: b'camera_trigger_canon_eos_capture [library.c:5783]': b"'ptp_canon_eos_remotereleaseon (params, 3, 0)' failed: Canon EOS M Full-Press failed (0x02ff: PTP I/O Error)"
+Gphoto2: b'gp_context_error': b'Canon EOS M Full-Press failed (0x02ff: PTP I/O Error)'
+Gphoto2: b'camera_canon_eos_capture [library.c:4223]': b"'camera_trigger_canon_eos_capture (camera, context)' failed: 'I/O problem' (-7)"
+Gphoto2: b'gp_camera_capture [gphoto2-camera.c:1339]': b"'camera->functions->capture (camera, type, path, context)' failed: -7"
+
+================================================================================
+ABORT   : exception occures: [-7] I/O problem
+If you are investigating why pibooth does not work with your DSLR camera,
+please paste the content of generated file 'diagnostic.log'
+on https://github.com/pibooth/pibooth/issues
diff --git a/tests/test_pictures.py b/tests/test_pictures.py
new file mode 100644
index 00000000..808fd607
--- /dev/null
+++ b/tests/test_pictures.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+
+import itertools
+import pygame
+from pibooth.pictures import text_to_pygame_image
+
+
+POSITIONS = ["top-left",
+             "top-center",
+             "top-right",
+             "center-left",
+             "center",
+             "center-right",
+             "bottom-left",
+             "bottom-center",
+             "bottom-right"]
+
+MULTILINES = ["This is a\ntop-left\ncool position",
+              "This is a\ntop-center\ncool position",
+              "This is a\ntop-right\ncool position",
+              "This is a\ncenter-left\ncool position",
+              "This is a\ncenter\ncool position",
+              "This is a\ncenter-right\ncool position",
+              "This is a\nbottom-left\ncool position",
+              "This is a\nbottom-center\ncool position",
+              "This is a\nbottom-right\ncool position"]
+
+
+def test_text_position(pygame_loop):
+
+    index = itertools.cycle(POSITIONS)
+
+    def handler(screen, events):
+        for event in events:
+            if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
+                text = next(index)
+                screen.fill((0, 0, 0))
+                surface = text_to_pygame_image(text, screen.get_size(),
+                                               color=(255, 255, 255),
+                                               align=text,
+                                               bg_color=(234, 23, 89))
+                screen.blit(surface, (0, 0))
+
+    pygame_loop(handler)
+
+
+def test_text_multilines(pygame_loop):
+
+    index = itertools.cycle(MULTILINES)
+
+    def handler(screen, events):
+        for event in events:
+            if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE:
+                text = next(index)
+                screen.fill((0, 0, 0))
+                surface = text_to_pygame_image(text, screen.get_size(),
+                                               color=(255, 255, 255),
+                                               align=POSITIONS[MULTILINES.index(text)],
+                                               bg_color=(234, 23, 89))
+                screen.blit(surface, (0, 0))
+
+    pygame_loop(handler)
diff --git a/tests/test_scene.py b/tests/test_scene.py
new file mode 100644
index 00000000..14504ce0
--- /dev/null
+++ b/tests/test_scene.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+
+import pygame
+
+
+def test_scene(init, pygame_loop, scene_builder):
+
+    scene = scene_builder('preview')
+
+    def handler(screen, events):
+        for event in events:
+            if event.type == pygame.VIDEORESIZE:
+                scene.set_background((0, 0, 0), event.size)
+                scene.resize(event.size)
+
+        scene.update(events)
+        return scene.draw(screen)
+
+    pygame_loop(handler)
diff --git a/tests/test_tasks.py b/tests/test_tasks.py
new file mode 100644
index 00000000..e4d6eb54
--- /dev/null
+++ b/tests/test_tasks.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+
+import time
+import pytest
+from concurrent import futures
+from pibooth.tasks import AsyncTask
+
+RESULT = {"Hello": "World"}
+
+
+def exec_foo(duration):
+    time.sleep(duration)
+    return RESULT
+
+
+def test_simple_task(init_tasks):
+    task = AsyncTask(exec_foo, (2,))
+    assert task.is_alive() == True
+    assert task.wait() == RESULT
+    assert task.is_alive() == False
+    assert task.result() == RESULT
+
+
+def test_unfinished_task(init_tasks):
+    task = AsyncTask(exec_foo, (2,))
+    assert task.result() == None
+    assert task.is_alive() == True
+
+
+def test_kill_tasks(init_tasks):
+    task = AsyncTask(exec_foo, (0.5,), loop=True)
+    time.sleep(1)
+    assert task.is_alive() == True
+    task.kill()
+    assert task.is_alive() == False
+    assert task.result() == RESULT
diff --git a/tests/test_window.py b/tests/test_window.py
deleted file mode 100644
index 9c14afa9..00000000
--- a/tests/test_window.py
+++ /dev/null
@@ -1,84 +0,0 @@
-# -*- coding: utf-8 -*-
-
-import pytest
-import pygame
-from pibooth.view.window import PiWindow
-
-
-WIN = PiWindow("Test", debug=True)
-
-
-def loop(func, *args, **kwargs):
-    fps = 40
-    clock = pygame.time.Clock()
-
-    while True:
-        for event in pygame.event.get():
-            if event.type == pygame.QUIT:
-                WIN.drop_cache()
-                return
-            if event.type == pygame.VIDEORESIZE:
-                WIN.resize(event.size)
-
-        func(*args, **kwargs)
-
-        pygame.display.update()
-        clock.tick(fps)
-
-
-def test_oops(init):
-    loop(WIN.show_oops)
-
-
-def test_intro(init):
-    loop(WIN.show_intro)
-
-
-def test_intro_portrait(init, captures_portrait):
-    loop(WIN.show_intro, captures_portrait[0])
-
-
-def test_intro_landscape(init, captures_landscape):
-    loop(WIN.show_intro, captures_landscape[0])
-
-
-@pytest.mark.parametrize('choices', [(1, 2), (1, 3), (1, 4)])
-def test_choice(init, choices):
-    loop(WIN.show_choice, choices)
-
-
-@pytest.mark.parametrize('selected', [1, 2, 3, 4])
-def test_choice_selected(init, selected):
-    loop(WIN.show_choice, (0, 0), selected)
-
-
-def test_preview(init):
-    loop(WIN.set_capture_number, 3, 5)
-
-
-def test_work_in_progress(init):
-    loop(WIN.show_work_in_progress)
-
-
-def test_print(init):
-    loop(WIN.show_print)
-
-
-def test_print_portrait(init, captures_portrait):
-    loop(WIN.show_print, captures_portrait[0])
-
-
-def test_print_landscape(init, captures_landscape):
-    loop(WIN.show_print, captures_landscape[0])
-
-
-def test_finished(init):
-    loop(WIN.show_finished)
-
-
-def test_finished_portrait(init, captures_portrait):
-    loop(WIN.show_finished, captures_portrait[0])
-
-
-def test_finished_landscape(init, captures_landscape):
-    loop(WIN.show_finished, captures_landscape[0])