diff --git a/Documentation/pycardium/simple_menu.rst b/Documentation/pycardium/simple_menu.rst
index 36758c643e7356e44c4343260c2aa529f651e9f1..58236e2c4691d91d5a0fe36e9a4862f50aeff748 100644
--- a/Documentation/pycardium/simple_menu.rst
+++ b/Documentation/pycardium/simple_menu.rst
@@ -25,4 +25,6 @@ displaying menus.  You can use it like this:
 .. autoclass:: simple_menu.Menu
    :members:
 
+.. autodata:: simple_menu.TIMEOUT
+
 .. autofunction:: simple_menu.button_events
diff --git a/preload/apps/personal_state/__init__.py b/preload/apps/personal_state/__init__.py
index 7605652c95070dba46cb8e572e48e2ebd2f6c641..f636e0c43cccfe9bd1781833a04b8776021e97ed 100644
--- a/preload/apps/personal_state/__init__.py
+++ b/preload/apps/personal_state/__init__.py
@@ -1,13 +1,11 @@
 """
 Personal State Script
-===========
-With this script you can 
+=====================
 """
-import buttons
 import color
-import display
 import os
 import personal_state
+import simple_menu
 
 states = [
     ("No State", personal_state.NO_STATE),
@@ -18,75 +16,35 @@ states = [
 ]
 
 
-def button_events():
-    """Iterate over button presses (event-loop)."""
-    yield 0
-    button_pressed = False
-    while True:
-        v = buttons.read(buttons.BOTTOM_LEFT | buttons.BOTTOM_RIGHT | buttons.TOP_RIGHT)
+class StateMenu(simple_menu.Menu):
+    color_sel = color.WHITE
 
-        if v == 0:
-            button_pressed = False
+    def on_scroll(self, item, index):
+        personal_state.set(item[1], False)
 
-        if not button_pressed and v & buttons.BOTTOM_LEFT != 0:
-            button_pressed = True
-            yield buttons.BOTTOM_LEFT
+    def on_select(self, item, index):
+        personal_state.set(item[1], True)
+        os.exit()
 
-        if not button_pressed and v & buttons.BOTTOM_RIGHT != 0:
-            button_pressed = True
-            yield buttons.BOTTOM_RIGHT
+    def draw_entry(self, item, index, offset):
+        if item[1] == personal_state.NO_CONTACT:
+            bg = color.RED
+            fg = color.WHITE
+        elif item[1] == personal_state.CHAOS:
+            bg = color.CHAOSBLUE
+            fg = color.CHAOSBLUE_DARK
+        elif item[1] == personal_state.COMMUNICATION:
+            bg = color.COMMYELLOW
+            fg = color.COMMYELLOW_DARK
+        elif item[1] == personal_state.CAMP:
+            bg = color.CAMPGREEN
+            fg = color.CAMPGREEN_DARK
+        else:
+            bg = color.Color(100, 100, 100)
+            fg = color.Color(200, 200, 200)
 
-        if not button_pressed and v & buttons.TOP_RIGHT != 0:
-            button_pressed = True
-            yield buttons.TOP_RIGHT
-
-
-COLOR1, COLOR2 = (color.CHAOSBLUE_DARK, color.CHAOSBLUE)
-
-
-def draw_menu(disp, idx, offset):
-    disp.clear()
-
-    for y, i in enumerate(range(len(states) + idx - 3, len(states) + idx + 4)):
-        selected = states[i % len(states)]
-        disp.print(
-            " " + selected[0] + " " * (11 - len(selected[0])),
-            posy=offset + y * 20 - 40,
-            bg=COLOR1 if i % 2 == 0 else COLOR2,
-        )
-
-    disp.print(">", posy=20, fg=color.COMMYELLOW, bg=COLOR2 if idx % 2 == 0 else COLOR1)
-    disp.update()
-
-
-def main():
-    disp = display.open()
-    numstates = len(states)
-
-    current, _ = personal_state.get()
-    for ev in button_events():
-        if ev == buttons.BOTTOM_RIGHT:
-            # Scroll down
-            draw_menu(disp, current, -8)
-            current = (current + 1) % numstates
-            state = states[current]
-            personal_state.set(state[1], False)
-        elif ev == buttons.BOTTOM_LEFT:
-            # Scroll up
-            draw_menu(disp, current, 8)
-            current = (current + numstates - 1) % numstates
-            state = states[current]
-            personal_state.set(state[1], False)
-        elif ev == buttons.TOP_RIGHT:
-            state = states[current]
-            personal_state.set(state[1], True)
-            # Select & start
-            disp.clear().update()
-            disp.close()
-            os.exit(0)
-
-        draw_menu(disp, current, 0)
+        self.disp.print(" " + str(item[0]) + " " * 9, posy=offset, fg=fg, bg=bg)
 
 
 if __name__ == "__main__":
-    main()
+    StateMenu(states).run()
diff --git a/preload/menu.py b/preload/menu.py
index b8479c1dbdf9600d2af700249a35a33a770e9ad1..e9077e3611920f1fdb74b33347fe8db4f88eca27 100644
--- a/preload/menu.py
+++ b/preload/menu.py
@@ -5,286 +5,91 @@ You can customize this script however you want :)  If you want to go back to
 the default version, just delete this file; the firmware will recreate it on
 next run.
 """
-import buttons
+import collections
 import color
 import display
 import os
-import utime
-import ujson
+import simple_menu
 import sys
+import ujson
+import utime
 
-BUTTON_TIMER_POPPED = -1
-COLOR_BG = color.CHAOSBLUE_DARK
-COLOR_BG_SEL = color.CHAOSBLUE
-COLOR_ARROW = color.COMMYELLOW
-COLOR_TEXT = color.COMMYELLOW
-MAXCHARS = 11
-HOMEAPP = "main.py"
-
-
-def create_folders():
-    try:
-        os.mkdir("/apps")
-    except:
-        pass
-
-
-def read_metadata(app_folder):
-    try:
-        info_file = "/apps/%s/metadata.json" % (app_folder)
-        with open(info_file) as f:
-            information = f.read()
-        return ujson.loads(information)
-    except Exception as e:
-        print("Failed to read metadata for %s" % (app_folder))
-        sys.print_exception(e)
-        return {
-            "author": "",
-            "name": app_folder,
-            "description": "",
-            "category": "",
-            "revision": 0,
-        }
+App = collections.namedtuple("App", ["name", "path"])
 
 
-def list_apps():
-    """Create a list of available apps."""
-    apps = []
+def enumerate_apps():
+    """List all installed apps."""
+    for f in os.listdir("/"):
+        if f == "main.py":
+            yield App("Home", f)
 
-    # add main application
-    for mainFile in os.listdir("/"):
-        if mainFile == HOMEAPP:
-            apps.append(
-                [
-                    "/%s" % HOMEAPP,
-                    {
-                        "author": "card10badge Team",
-                        "name": "Home",
-                        "description": "",
-                        "category": "",
-                        "revision": 0,
-                    },
-                ]
-            )
-
-    dirlist = [
-        entry for entry in sorted(os.listdir("/apps")) if not entry.startswith(".")
-    ]
+    for app in sorted(os.listdir("/apps")):
+        if app.startswith("."):
+            continue
 
-    # list all hatchary style apps (not .elf and not .py)
-    # with or without metadata.json
-    for appFolder in dirlist:
-        if not (appFolder.endswith(".py") or appFolder.endswith(".elf")):
-            metadata = read_metadata(appFolder)
-            if not metadata.get("bin", None):
-                fileName = "/apps/%s/__init__.py" % appFolder
-            else:
-                fileName = "/apps/%s/%s" % (appFolder, metadata["bin"])
-            apps.append([fileName, metadata])
+        if app.endswith(".py") or app.endswith(".elf"):
+            yield App(app, "/apps/" + app)
+            continue
 
-    # list simple python scripts
-    for pyFile in dirlist:
-        if pyFile.endswith(".py"):
-            apps.append(
-                [
-                    "/apps/%s" % pyFile,
-                    {
-                        "author": "",
-                        "name": pyFile,
-                        "description": "",
-                        "category": "",
-                        "revision": 0,
-                    },
-                ]
-            )
+        try:
+            with open("/apps/" + app + "/metadata.json") as f:
+                info = ujson.load(f)
 
-    # list simple elf binaries
-    for elfFile in dirlist:
-        if elfFile.endswith(".elf"):
-            apps.append(
-                [
-                    "/apps/%s" % elfFile,
-                    {
-                        "author": "",
-                        "name": elfFile,
-                        "description": "",
-                        "category": "",
-                        "revision": 0,
-                    },
-                ]
+            yield App(
+                info["name"], "/apps/{}/{}".format(app, info.get("bin", "__init__.py"))
             )
+        except Exception as e:
+            print("Could not load /apps/{}/metadata.json!".format(app))
+            sys.print_exception(e)
+
+
+class MainMenu(simple_menu.Menu):
+    timeout = 30.0
+
+    def entry2name(self, app):
+        return app.name
+
+    def on_select(self, app, index):
+        self.disp.clear().update()
+        try:
+            print("Trying to load " + app.path)
+            os.exec(app.path)
+        except OSError as e:
+            print("Loading failed: ")
+            sys.print_exception(e)
+            self.error("Loading", "failed")
+            utime.sleep(1.0)
+            os.exit(1)
+
+    def on_timeout(self):
+        try:
+            f = open("main.py")
+            f.close()
+            os.exec("main.py")
+        except OSError:
+            pass
+
+
+def no_apps_message():
+    """Display a warning if no apps are installed."""
+    with display.open() as disp:
+        disp.clear(color.COMMYELLOW)
+        disp.print(
+            " No apps ", posx=17, posy=20, fg=color.COMMYELLOW_DARK, bg=color.COMMYELLOW
+        )
+        disp.print(
+            "available", posx=17, posy=40, fg=color.COMMYELLOW_DARK, bg=color.COMMYELLOW
+        )
+        disp.update()
 
-    return apps
-
-
-def button_events(timeout=0):
-    """Iterate over button presses (event-loop)."""
-    yield 0
-    button_pressed = False
-    count = 0
     while True:
-        v = buttons.read(buttons.BOTTOM_LEFT | buttons.BOTTOM_RIGHT | buttons.TOP_RIGHT)
-        if timeout > 0 and count > 0 and count % timeout == 0:
-            yield BUTTON_TIMER_POPPED
-
-        if timeout > 0:
-            count += 1
-
-        if v == 0:
-            button_pressed = False
-
-        if not button_pressed and v & buttons.BOTTOM_LEFT != 0:
-            button_pressed = True
-            yield buttons.BOTTOM_LEFT
-
-        if not button_pressed and v & buttons.BOTTOM_RIGHT != 0:
-            button_pressed = True
-            yield buttons.BOTTOM_RIGHT
-
-        if not button_pressed and v & buttons.TOP_RIGHT != 0:
-            button_pressed = True
-            yield buttons.TOP_RIGHT
-
-        utime.sleep_ms(10)
-
+        utime.sleep(0.5)
 
-def triangle(disp, x, y, left, scale=6, color=[255, 0, 0]):
-    """Draw a triangle to show there's more text in this line"""
-    yf = 1 if left else -1
-    disp.line(x - scale * yf, int(y + scale / 2), x, y, col=color)
-    disp.line(x, y, x, y + scale, col=color)
-    disp.line(x, y + scale, x - scale * yf, y + int(scale / 2), col=color)
 
+if __name__ == "__main__":
+    apps = list(enumerate_apps())
 
-def draw_menu(disp, applist, pos, appcount, lineoffset):
-    disp.clear()
-
-    start = 0
-    if pos > 0:
-        start = pos - 1
-    if start + 4 > appcount:
-        start = appcount - 4
-    if start < 0:
-        start = 0
-
-    for i, app in enumerate(applist):
-        if i >= start + 4 or i >= appcount:
-            break
-        if i >= start:
-            disp.rect(
-                0,
-                (i - start) * 20,
-                159,
-                (i - start) * 20 + 20,
-                col=COLOR_BG_SEL if i == pos else COLOR_BG,
-            )
-
-            line = app[1]["name"]
-            linelength = len(line)
-            off = 0
-
-            # calc line offset for scrolling
-            if i == pos and linelength > (MAXCHARS - 1) and lineoffset > 0:
-                off = (
-                    lineoffset
-                    if lineoffset + (MAXCHARS - 1) < linelength
-                    else linelength - (MAXCHARS - 1)
-                )
-            if lineoffset > linelength:
-                off = 0
-
-            disp.print(
-                " " + line[off : (off + (MAXCHARS - 1))],
-                posy=(i - start) * 20,
-                fg=COLOR_TEXT,
-                bg=COLOR_BG_SEL if i == pos else COLOR_BG,
-            )
-            if i == pos:
-                disp.print(">", posy=(i - start) * 20, fg=COLOR_ARROW, bg=COLOR_BG_SEL)
-
-            if linelength > (MAXCHARS - 1) and off < linelength - (MAXCHARS - 1):
-                triangle(disp, 153, (i - start) * 20 + 6, False, 6)
-                triangle(disp, 154, (i - start) * 20 + 7, False, 4)
-                triangle(disp, 155, (i - start) * 20 + 8, False, 2)
-            if off > 0:
-                triangle(disp, 24, (i - start) * 20 + 6, True, 6)
-                triangle(disp, 23, (i - start) * 20 + 7, True, 4)
-                triangle(disp, 22, (i - start) * 20 + 8, True, 2)
-
-    disp.update()
-
-
-def main():
-    create_folders()
-    disp = display.open()
-    applist = list_apps()
-    numapps = len(applist)
-    current = 0
-    lineoffset = 0
-    timerscrollspeed = 1
-    timerstartscroll = 5
-    timercountpopped = 0
-    timerinactivity = 100
-    for ev in button_events(10):
-        if numapps == 0:
-            disp.clear(COLOR_BG)
-            disp.print(" No apps ", posx=17, posy=20, fg=COLOR_TEXT, bg=COLOR_BG)
-            disp.print("available", posx=17, posy=40, fg=COLOR_TEXT, bg=COLOR_BG)
-            disp.update()
-            continue
-
-        if ev == buttons.BOTTOM_RIGHT:
-            # Scroll down
-            current = (current + 1) % numapps
-            lineoffset = 0
-            timercountpopped = 0
-
-        elif ev == buttons.BOTTOM_LEFT:
-            # Scroll up
-            current = (current + numapps - 1) % numapps
-            lineoffset = 0
-            timercountpopped = 0
-
-        elif ev == BUTTON_TIMER_POPPED:
-            timercountpopped += 1
-            if (
-                timercountpopped >= timerstartscroll
-                and (timercountpopped - timerstartscroll) % timerscrollspeed == 0
-            ):
-                lineoffset += 1
-
-            if applist[0][0] == "/%s" % HOMEAPP and timercountpopped >= timerinactivity:
-                print("Inactivity timer popped")
-                disp.clear().update()
-                disp.close()
-                try:
-                    os.exec("/%s" % HOMEAPP)
-                except OSError as e:
-                    print("Loading failed: ", e)
-                    os.exit(1)
-
-        elif ev == buttons.TOP_RIGHT:
-            # Select & start
-            disp.clear().update()
-            disp.close()
-            try:
-                os.exec(applist[current][0])
-            except OSError as e:
-                print("Loading failed: ", e)
-                os.exit(1)
-
-        draw_menu(disp, applist, current, numapps, lineoffset)
-
+    if apps == []:
+        no_apps_message()
 
-if __name__ == "__main__":
-    try:
-        main()
-    except Exception as e:
-        sys.print_exception(e)
-        with display.open() as d:
-            d.clear(color.COMMYELLOW)
-            d.print("Menu", posx=52, posy=20, fg=color.COMMYELLOW_DARK, bg=color.COMMYELLOW)
-            d.print("crashed", posx=31, posy=40, fg=color.COMMYELLOW_DARK, bg=color.COMMYELLOW)
-            d.update()
-            utime.sleep(2)
-        os.exit(1)
+    MainMenu(apps).run()
diff --git a/pycardium/modules/py/simple_menu.py b/pycardium/modules/py/simple_menu.py
index 42b117cc7998ac94397bee646f1c3fa82538af11..b9c535eeac14fcec5048b7a09377ee615161ec34 100644
--- a/pycardium/modules/py/simple_menu.py
+++ b/pycardium/modules/py/simple_menu.py
@@ -1,9 +1,14 @@
 import buttons
 import color
 import display
+import sys
+import utime
 
+TIMEOUT = 0x100
+""":py:func:`~simple_menu.button_events` timeout marker."""
 
-def button_events():
+
+def button_events(timeout=None):
     """
     Iterate over button presses (event-loop).
 
@@ -26,11 +31,30 @@ def button_events():
                 pass
 
     .. versionadded:: 1.4
+
+    :param float,optional timeout:
+       Timeout after which the generator should yield in any case.  If a
+       timeout is defined, the generator will periodically yield
+       :py:data:`simple_menu.TIMEOUT`.
+
+       .. versionadded:: 1.9
     """
     yield 0
+
     v = buttons.read(buttons.BOTTOM_LEFT | buttons.BOTTOM_RIGHT | buttons.TOP_RIGHT)
     button_pressed = True if v != 0 else False
+
+    if timeout is not None:
+        timeout = int(timeout * 1000)
+        next_tick = utime.time_ms() + timeout
+
     while True:
+        if timeout is not None:
+            current_time = utime.time_ms()
+            if current_time >= next_tick:
+                next_tick += timeout
+                yield TIMEOUT
+
         v = buttons.read(buttons.BOTTOM_LEFT | buttons.BOTTOM_RIGHT | buttons.TOP_RIGHT)
 
         if v == 0:
@@ -49,6 +73,10 @@ def button_events():
             yield buttons.TOP_RIGHT
 
 
+class _ExitMenuException(Exception):
+    pass
+
+
 class Menu:
     """
     A simple menu for card10.
@@ -77,6 +105,21 @@ class Menu:
     color_sel = color.COMMYELLOW
     """Color of the selector."""
 
+    scroll_speed = 0.5
+    """
+    Time to wait before scrolling to the right.
+
+    .. versionadded:: 1.9
+    """
+
+    timeout = None
+    """
+    Optional timeout for inactivity.  Once this timeout is reached,
+    :py:meth:`~simple_menu.Menu.on_timeout` will be called.
+
+    .. versionadded:: 1.9
+    """
+
     def on_scroll(self, item, index):
         """
         Hook when the selector scrolls to a new item.
@@ -102,12 +145,30 @@ class Menu:
         """
         pass
 
+    def on_timeout(self):
+        """
+        The inactivity timeout has been triggered.  See
+        :py:attr:`simple_menu.Menu.timeout`.
+
+        .. versionadded:: 1.9
+        """
+        self.exit()
+
+    def exit(self):
+        """
+        Exit the event-loop.  This should be called from inside an ``on_*`` hook.
+
+        .. versionadded:: 1.9
+        """
+        raise _ExitMenuException()
+
     def __init__(self, entries):
         if len(entries) == 0:
             raise ValueError("at least one entry is required")
 
         self.entries = entries
         self.idx = 0
+        self.select_time = utime.time_ms()
         self.disp = display.open()
 
     def entry2name(self, value):
@@ -142,8 +203,21 @@ class Menu:
             but **not** an index into ``entries``.
         :param int offset: Y-offset for this entry.
         """
+        string = self.entry2name(value)
+
+        if offset != 20 or len(string) < 10:
+            string = " " + string + " " * 9
+        else:
+            # Slowly scroll entry to the side
+            time_offset = (utime.time_ms() - self.select_time) // int(
+                self.scroll_speed * 1000
+            )
+            time_offset = time_offset % (len(string) - 7) - 1
+            time_offset = min(len(string) - 10, max(0, time_offset))
+            string = " " + string[time_offset:]
+
         self.disp.print(
-            " " + self.entry2name(value) + " " * 9,
+            string,
             posy=offset,
             fg=self.color_text,
             bg=self.color_1 if index % 2 == 0 else self.color_2,
@@ -171,18 +245,70 @@ class Menu:
 
         self.disp.update()
 
+    def error(self, line1, line2=""):
+        """
+        Display an error message.
+
+        :param str line1: First line of the error message.
+        :param str line2: Second line of the error message.
+
+        .. versionadded:: 1.9
+        """
+        self.disp.clear(color.COMMYELLOW)
+
+        offset = max(0, (160 - len(line1) * 14) // 2)
+        self.disp.print(
+            line1, posx=offset, posy=20, fg=color.COMMYELLOW_DARK, bg=color.COMMYELLOW
+        )
+
+        offset = max(0, (160 - len(line2) * 14) // 2)
+        self.disp.print(
+            line2, posx=offset, posy=40, fg=color.COMMYELLOW_DARK, bg=color.COMMYELLOW
+        )
+
+        self.disp.update()
+
     def run(self):
         """Start the event-loop."""
-        for ev in button_events():
-            if ev == buttons.BOTTOM_RIGHT:
-                self.draw_menu(-8)
-                self.idx = (self.idx + 1) % len(self.entries)
-                self.on_scroll(self.entries[self.idx], self.idx)
-            elif ev == buttons.BOTTOM_LEFT:
-                self.draw_menu(8)
-                self.idx = (self.idx + len(self.entries) - 1) % len(self.entries)
-                self.on_scroll(self.entries[self.idx], self.idx)
-            elif ev == buttons.TOP_RIGHT:
-                self.on_select(self.entries[self.idx], self.idx)
-
-            self.draw_menu()
+        try:
+            timeout = self.scroll_speed
+            if self.timeout is not None and self.timeout < self.scroll_speed:
+                timeout = self.timeout
+
+            for ev in button_events(timeout):
+                if ev == buttons.BOTTOM_RIGHT:
+                    self.select_time = utime.time_ms()
+                    self.draw_menu(-8)
+                    self.idx = (self.idx + 1) % len(self.entries)
+                    try:
+                        self.on_scroll(self.entries[self.idx], self.idx)
+                    except Exception as e:
+                        print("Exception during menu.on_scroll():")
+                        sys.print_exception(e)
+                elif ev == buttons.BOTTOM_LEFT:
+                    self.select_time = utime.time_ms()
+                    self.draw_menu(8)
+                    self.idx = (self.idx + len(self.entries) - 1) % len(self.entries)
+                    try:
+                        self.on_scroll(self.entries[self.idx], self.idx)
+                    except Exception as e:
+                        print("Exception during menu.on_scroll():")
+                        sys.print_exception(e)
+                elif ev == buttons.TOP_RIGHT:
+                    try:
+                        self.on_select(self.entries[self.idx], self.idx)
+                        self.select_time = utime.time_ms()
+                    except Exception as e:
+                        print("Menu crashed!")
+                        sys.print_exception(e)
+                        self.error("Menu", "crashed")
+                        utime.sleep(1.0)
+
+                self.draw_menu()
+
+                if self.timeout is not None and (
+                    utime.time_ms() - self.select_time
+                ) > int(self.timeout * 1000):
+                    self.on_timeout()
+        except _ExitMenuException:
+            pass