Merge pull request #39 from ihabunek/curses-resize - toot - Unnamed repository; edit this file 'description' to name the repository.
 (DIR) Log
 (DIR) Files
 (DIR) Refs
 (DIR) LICENSE
       ---
 (DIR) commit d3d69509cb6b2c22bd202bd66a20bc3e9a555cc2
 (DIR) parent b444b06116eda5dc55856e77c9ea593102911a1f
 (HTM) Author: Ivan Habunek <ivan@habunek.com>
       Date:   Sun, 14 Jan 2018 15:49:41 +0100
       
       Merge pull request #39 from ihabunek/curses-resize
       
       Improvements to the curses app
       Diffstat:
         toot/api.py                         |      33 +++++++++++++++++++++++--------
         toot/app.py                         |     254 -------------------------------
         toot/commands.py                    |      13 +++++++++++--
         toot/console.py                     |      14 ++++++++++++--
         toot/ui/__init__.py                 |       0 
         toot/ui/app.py                      |     375 +++++++++++++++++++++++++++++++
         toot/ui/utils.py                    |      28 ++++++++++++++++++++++++++++
         toot/utils.py                       |       8 ++++++++
       
       8 files changed, 459 insertions(+), 266 deletions(-)
       ---
 (DIR) diff --git a/toot/api.py b/toot/api.py
       @@ -88,21 +88,38 @@ def timeline_home(app, user):
            return http.get(app, user, '/api/v1/timelines/home').json()
        
        
       -def _get_next_path(headers):
       +def get_next_path(headers):
       +    """Given timeline response headers, returns the path to the next batch"""
            links = headers.get('Link', '')
            matches = re.match('<([^>]+)>; rel="next"', links)
            if matches:
       -        url = matches.group(1)
       -        return urlparse(url).path
       +        parsed = urlparse(matches.group(1))
       +        return "?".join([parsed.path, parsed.query])
        
        
       -def timeline_generator(app, user):
       -    next_path = '/api/v1/timelines/home'
       +def _timeline_generator(app, user, path, limit=20):
       +    while path:
       +        response = http.get(app, user, path)
       +        yield response.json()
       +        path = get_next_path(response.headers)
       +
        
       -    while next_path:
       -        response = http.get(app, user, next_path)
       +def _anon_timeline_generator(instance, path, limit=20):
       +    while path:
       +        url = "https://{}{}".format(instance, path)
       +        response = http.anon_get(url, path)
                yield response.json()
       -        next_path = _get_next_path(response.headers)
       +        path = get_next_path(response.headers)
       +
       +
       +def home_timeline_generator(app, user, limit=20):
       +    path = '/api/v1/timelines/home?limit={}'.format(limit)
       +    return _timeline_generator(app, user, path)
       +
       +
       +def public_timeline_generator(instance, limit=20):
       +    path = '/api/v1/timelines/public?limit={}'.format(limit)
       +    return _anon_timeline_generator(instance, path)
        
        
        def upload_media(app, user, file):
 (DIR) diff --git a/toot/app.py b/toot/app.py
       @@ -1,254 +0,0 @@
       -# -*- coding: utf-8 -*-
       -
       -import webbrowser
       -
       -from textwrap import wrap
       -
       -from toot.exceptions import ConsoleError
       -from toot.utils import format_content
       -
       -# Attempt to load curses, which is not available on windows
       -try:
       -    import curses
       -except ImportError as e:
       -    raise ConsoleError("Curses is not available on this platform")
       -
       -
       -class Color:
       -    @staticmethod
       -    def setup_palette():
       -        curses.init_pair(1, curses.COLOR_BLUE, curses.COLOR_BLACK)
       -        curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
       -        curses.init_pair(3, curses.COLOR_YELLOW, curses.COLOR_BLACK)
       -
       -    @staticmethod
       -    def blue():
       -        return curses.color_pair(1)
       -
       -    @staticmethod
       -    def green():
       -        return curses.color_pair(2)
       -
       -    @staticmethod
       -    def yellow():
       -        return curses.color_pair(3)
       -
       -
       -class TimelineApp:
       -    def __init__(self, status_generator):
       -        self.status_generator = status_generator
       -        self.statuses = []
       -        self.selected = None
       -
       -    def run(self):
       -        curses.wrapper(self._wrapped_run)
       -
       -    def _wrapped_run(self, stdscr):
       -        self.left_width = 60
       -        self.right_width = curses.COLS - self.left_width
       -
       -        # Setup windows
       -        self.top = curses.newwin(2, curses.COLS, 0, 0)
       -        self.left = curses.newpad(curses.LINES * 2, self.left_width)
       -        self.right = curses.newwin(curses.LINES - 4, self.right_width, 2, self.left_width)
       -        self.bottom = curses.newwin(2, curses.COLS, curses.LINES - 2, 0)
       -
       -        Color.setup_palette()
       -
       -        # Load some data and redraw
       -        self.fetch_next()
       -        self.selected = 0
       -        self.full_redraw()
       -
       -        self.loop()
       -
       -    def loop(self):
       -        while True:
       -            key = self.left.getkey()
       -
       -            if key.lower() == 'q':
       -                return
       -
       -            elif key.lower() == 'v':
       -                status = self.get_selected_status()
       -                if status:
       -                    webbrowser.open(status['url'])
       -
       -            elif key.lower() == 'j' or key == curses.KEY_DOWN:
       -                self.select_next()
       -
       -            elif key.lower() == 'k' or key == curses.KEY_UP:
       -                self.select_previous()
       -
       -    def select_previous(self):
       -        """Move to the previous status in the timeline."""
       -        if self.selected == 0:
       -            return
       -
       -        old_index = self.selected
       -        new_index = self.selected - 1
       -
       -        self.selected = new_index
       -        self.redraw_after_selection_change(old_index, new_index)
       -
       -    def select_next(self):
       -        """Move to the next status in the timeline."""
       -        if self.selected + 1 >= len(self.statuses):
       -            return
       -
       -        old_index = self.selected
       -        new_index = self.selected + 1
       -
       -        self.selected = new_index
       -        self.redraw_after_selection_change(old_index, new_index)
       -
       -    def redraw_after_selection_change(self, old_index, new_index):
       -        old_status = self.statuses[old_index]
       -        new_status = self.statuses[new_index]
       -
       -        # Perform a partial redraw
       -        self.draw_status_row(self.left, old_status, 3 * old_index - 1, False)
       -        self.draw_status_row(self.left, new_status, 3 * new_index - 1, True)
       -        self.draw_status_details(self.right, new_status)
       -
       -    def fetch_next(self):
       -        try:
       -            statuses = next(self.status_generator)
       -        except StopIteration:
       -            return None
       -
       -        for status in statuses:
       -            self.statuses.append(parse_status(status))
       -
       -        return len(statuses)
       -
       -    def full_redraw(self):
       -        """Perform a full redraw of the UI."""
       -        self.left.clear()
       -        self.right.clear()
       -        self.top.clear()
       -        self.bottom.clear()
       -
       -        self.left.box()
       -        self.right.box()
       -
       -        self.top.addstr(" toot - your Mastodon command line interface\n", Color.yellow())
       -        self.top.addstr(" https://github.com/ihabunek/toot")
       -
       -        self.draw_statuses(self.left)
       -        self.draw_status_details(self.right, self.get_selected_status())
       -        self.draw_usage(self.bottom)
       -
       -        self.left.refresh(0, 0, 2, 0, curses.LINES - 4, self.left_width)
       -
       -        self.right.refresh()
       -        self.top.refresh()
       -        self.bottom.refresh()
       -
       -    def draw_usage(self, window):
       -        # Show usage on the bottom
       -        window.addstr("Usage: | ")
       -        window.addch("j", Color.green())
       -        window.addstr(" next | ")
       -        window.addch("k", Color.green())
       -        window.addstr(" previous | ")
       -        window.addch("v", Color.green())
       -        window.addstr(" open in browser | ")
       -        window.addch("q", Color.green())
       -        window.addstr(" quit")
       -
       -        window.refresh()
       -
       -    def get_selected_status(self):
       -        if len(self.statuses) > self.selected:
       -            return self.statuses[self.selected]
       -
       -    def draw_status_row(self, window, status, offset, highlight=False):
       -        width = window.getmaxyx()[1]
       -        color = Color.blue() if highlight else 0
       -
       -        date, time = status['created_at']
       -        window.addstr(offset + 2, 2, date, color)
       -        window.addstr(offset + 3, 2, time, color)
       -
       -        window.addstr(offset + 2, 15, status['account']['acct'], color)
       -        window.addstr(offset + 3, 15, status['account']['display_name'], color)
       -
       -        window.addstr(offset + 4, 1, '─' * (width - 2))
       -
       -        window.refresh(0, 0, 2, 0, curses.LINES - 4, self.left_width)
       -
       -    def draw_statuses(self, window):
       -        for index, status in enumerate(self.statuses):
       -            offset = 3 * index - 1
       -            highlight = self.selected == index
       -            self.draw_status_row(window, status, offset, highlight)
       -
       -    def draw_status_details(self, window, status):
       -        window.erase()
       -        window.box()
       -
       -        acct = status['account']['acct']
       -        name = status['account']['display_name']
       -
       -        window.addstr(1, 2, "@" + acct, Color.green())
       -        window.addstr(2, 2, name, Color.yellow())
       -
       -        y = 4
       -        text_width = self.right_width - 4
       -
       -        for line in status['lines']:
       -            wrapped_lines = wrap(line, text_width) if line else ['']
       -            for wrapped_line in wrapped_lines:
       -                window.addstr(y, 2, wrapped_line.ljust(text_width))
       -                y = y + 1
       -
       -        if status['media_attachments']:
       -            y += 1
       -            for attachment in status['media_attachments']:
       -                url = attachment['text_url'] or attachment['url']
       -                for line in wrap(url, text_width):
       -                    window.addstr(y, 2, line)
       -                    y += 1
       -
       -        window.addstr(y, 1, '-' * (text_width + 2))
       -        y += 1
       -
       -        if status['url'] is not None:
       -            window.addstr(y, 2, status['url'])
       -            y += 1
       -
       -        if status['boosted_by']:
       -            acct = status['boosted_by']['acct']
       -            window.addstr(y, 2, "Boosted by ")
       -            window.addstr("@", Color.green())
       -            window.addstr(acct, Color.green())
       -            y += 1
       -
       -        window.refresh()
       -
       -
       -def parse_status(status):
       -    _status = status.get('reblog') or status
       -    account = parse_account(_status['account'])
       -    lines = list(format_content(_status['content']))
       -
       -    created_at = status['created_at'][:19].split('T')
       -    boosted_by = parse_account(status['account']) if status['reblog'] else None
       -
       -    return {
       -        'account': account,
       -        'boosted_by': boosted_by,
       -        'created_at': created_at,
       -        'lines': lines,
       -        'media_attachments': _status['media_attachments'],
       -        'url': status['url'],
       -    }
       -
       -
       -def parse_account(account):
       -    return {
       -        'id': account['id'],
       -        'acct': account['acct'],
       -        'display_name': account['display_name'],
       -    }
 (DIR) diff --git a/toot/commands.py b/toot/commands.py
       @@ -64,8 +64,17 @@ def timeline(app, user, args):
        
        
        def curses(app, user, args):
       -    from toot.app import TimelineApp
       -    generator = api.timeline_generator(app, user)
       +    from toot.ui.app import TimelineApp
       +
       +    if not args.public and (not app or not user):
       +        raise ConsoleError("You must be logged in to view the home timeline.")
       +
       +    if args.public:
       +        instance = args.instance or app.instance
       +        generator = api.public_timeline_generator(instance)
       +    else:
       +        generator = api.home_timeline_generator(app, user)
       +
            TimelineApp(generator).run()
        
        
 (DIR) diff --git a/toot/console.py b/toot/console.py
       @@ -138,8 +138,18 @@ READ_COMMANDS = [
            Command(
                name="curses",
                description="An experimental timeline app (doesn't work on Windows)",
       -        arguments=[],
       -        require_auth=True,
       +        arguments=[
       +            (["-p", "--public"], {
       +                "action": 'store_true',
       +                "default": False,
       +                "help": "Resolve non-local accounts",
       +            }),
       +            (["-i", "--instance"], {
       +                "type": str,
       +                "help": 'instance from which to read (for public timeline only)',
       +            })
       +        ],
       +        require_auth=False,
            ),
        ]
        
 (DIR) diff --git a/toot/ui/__init__.py b/toot/ui/__init__.py
 (DIR) diff --git a/toot/ui/app.py b/toot/ui/app.py
       @@ -0,0 +1,375 @@
       +# -*- coding: utf-8 -*-
       +
       +import webbrowser
       +
       +from textwrap import wrap
       +
       +from toot.exceptions import ConsoleError
       +from toot.ui.utils import draw_horizontal_divider, draw_lines
       +from toot.utils import format_content, trunc
       +
       +# Attempt to load curses, which is not available on windows
       +try:
       +    import curses
       +except ImportError as e:
       +    raise ConsoleError("Curses is not available on this platform")
       +
       +
       +class Color:
       +    @classmethod
       +    def setup_palette(class_):
       +        curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLACK)
       +        curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_BLACK)
       +        curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)
       +        curses.init_pair(4, curses.COLOR_YELLOW, curses.COLOR_BLACK)
       +        curses.init_pair(5, curses.COLOR_RED, curses.COLOR_BLACK)
       +        curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_BLUE)
       +
       +        class_.WHITE = curses.color_pair(1)
       +        class_.BLUE = curses.color_pair(2)
       +        class_.GREEN = curses.color_pair(3)
       +        class_.YELLOW = curses.color_pair(4)
       +        class_.RED = curses.color_pair(5)
       +        class_.WHITE_ON_BLUE = curses.color_pair(6)
       +
       +
       +class HeaderWindow:
       +    def __init__(self, height, width, y, x):
       +        self.window = curses.newwin(height, width, y, x)
       +        self.height = height
       +        self.width = width
       +
       +    def draw(self):
       +        self.window.erase()
       +        self.window.addstr(0, 1, "toot - your Mastodon command line interface", Color.YELLOW)
       +        self.window.addstr(1, 1, "https://github.com/ihabunek/toot")
       +        self.window.refresh()
       +
       +
       +class FooterWindow:
       +    def __init__(self, height, width, y, x):
       +        self.window = curses.newwin(height, width, y, x)
       +        self.height = height
       +        self.width = width
       +
       +    def draw_status(self, selected, count):
       +        text = "Showing toot {} of {}".format(selected + 1, count)
       +        text = trunc(text, self.width - 1).ljust(self.width - 1)
       +        self.window.addstr(0, 0, text, Color.WHITE_ON_BLUE | curses.A_BOLD)
       +        self.window.refresh()
       +
       +    def draw_message(self, text, color):
       +        text = trunc(text, self.width - 1).ljust(self.width - 1)
       +        self.window.addstr(1, 0, text, color)
       +        self.window.refresh()
       +
       +    def clear_message(self):
       +        self.window.addstr(1, 0, "".ljust(self.width - 1))
       +        self.window.refresh()
       +
       +
       +class StatusListWindow:
       +    """Window which shows the scrollable list of statuses (left side)."""
       +    def __init__(self, height, width, top, left):
       +        # Dimensions and position of region in stdscr which will contain the pad
       +        self.region_height = height
       +        self.region_width = width
       +        self.region_top = top
       +        self.region_left = left
       +
       +        # How many statuses fit on one page (excluding border, at 3 lines per status)
       +        self.page_size = (height - 2) // 3
       +
       +        # Initially, size the pad to the dimensions of the region, will be
       +        # increased later to accomodate statuses
       +        self.pad = curses.newpad(10, width)
       +        self.pad.box()
       +
       +        self.scroll_pos = 0
       +
       +    def draw_statuses(self, statuses, selected, starting=0):
       +        # Resize window to accomodate statuses if required
       +        height, width = self.pad.getmaxyx()
       +
       +        new_height = len(statuses) * 3 + 1
       +        if new_height > height:
       +            self.pad.resize(new_height, width)
       +            self.pad.box()
       +
       +        last_idx = len(statuses) - 1
       +
       +        for index, status in enumerate(statuses):
       +            if index >= starting:
       +                highlight = selected == index
       +                draw_divider = index < last_idx
       +                self.draw_status_row(status, index, highlight, draw_divider)
       +
       +    def draw_status_row(self, status, index, highlight=False, draw_divider=True):
       +        offset = 3 * index
       +
       +        height, width = self.pad.getmaxyx()
       +        color = Color.GREEN if highlight else Color.WHITE
       +
       +        date, time = status['created_at']
       +        self.pad.addstr(offset + 1, 1, " " + date.ljust(14), color)
       +        self.pad.addstr(offset + 2, 1, " " + time.ljust(14), color)
       +
       +        trunc_width = width - 15
       +        acct = trunc("@" + status['account']['acct'], trunc_width).ljust(trunc_width)
       +        display_name = trunc(status['account']['display_name'], trunc_width).ljust(trunc_width)
       +
       +        if status['account']['display_name']:
       +            self.pad.addstr(offset + 1, 14, display_name, color)
       +            self.pad.addstr(offset + 2, 14, acct, color)
       +        else:
       +            self.pad.addstr(offset + 1, 14, acct, color)
       +
       +        if draw_divider:
       +            draw_horizontal_divider(self.pad, offset + 3)
       +
       +        self.refresh()
       +
       +    def refresh(self):
       +        self.pad.refresh(
       +            self.scroll_pos * 3,  # top
       +            0,                    # left
       +            self.region_top,
       +            self.region_left,
       +            self.region_height + 1,  # +1 required to refresh full height, not sure why
       +            self.region_width,
       +        )
       +
       +    def scroll_to(self, index):
       +        self.scroll_pos = index
       +        self.refresh()
       +
       +    def scroll_up(self):
       +        if self.scroll_pos > 0:
       +            self.scroll_to(self.scroll_pos - 1)
       +
       +    def scroll_down(self):
       +        self.scroll_to(self.scroll_pos + 1)
       +
       +    def scroll_if_required(self, new_index):
       +        if new_index < self.scroll_pos:
       +            self.scroll_up()
       +        elif new_index >= self.scroll_pos + self.page_size:
       +            self.scroll_down()
       +        else:
       +            self.refresh()
       +
       +
       +class StatusDetailWindow:
       +    """Window which shows details of a status (right side)"""
       +    def __init__(self, height, width, y, x):
       +        self.window = curses.newwin(height, width, y, x)
       +        self.height = height
       +        self.width = width
       +
       +    def content_lines(self, status):
       +        acct = status['account']['acct']
       +        name = status['account']['display_name']
       +
       +        if name:
       +            yield name, Color.YELLOW
       +        yield "@" + acct, Color.GREEN
       +        yield
       +
       +        text_width = self.width - 4
       +
       +        for line in status['lines']:
       +            wrapped_lines = wrap(line, text_width) if line else ['']
       +            for wrapped_line in wrapped_lines:
       +                yield wrapped_line.ljust(text_width)
       +
       +        if status['media_attachments']:
       +            yield
       +            yield "Media:"
       +            for attachment in status['media_attachments']:
       +                url = attachment['text_url'] or attachment['url']
       +                for line in wrap(url, text_width):
       +                    yield line
       +
       +    def footer_lines(self, status):
       +        text_width = self.width - 4
       +
       +        if status['url'] is not None:
       +            for line in wrap(status['url'], text_width):
       +                yield line
       +
       +        if status['boosted_by']:
       +            acct = status['boosted_by']['acct']
       +            yield "Boosted by @{}".format(acct), Color.BLUE
       +
       +    def draw(self, status):
       +        self.window.erase()
       +        self.window.box()
       +
       +        if not status:
       +            return
       +
       +        content = self.content_lines(status)
       +        footer = self.footer_lines(status)
       +
       +        y = draw_lines(self.window, content, 2, 1, Color.WHITE)
       +        draw_horizontal_divider(self.window, y)
       +        draw_lines(self.window, footer, 2, y + 1, Color.WHITE)
       +
       +        self.window.refresh()
       +
       +
       +class TimelineApp:
       +    def __init__(self, status_generator):
       +        self.status_generator = status_generator
       +        self.statuses = []
       +        self.stdscr = None
       +
       +    def run(self):
       +        curses.wrapper(self._wrapped_run)
       +
       +    def _wrapped_run(self, stdscr):
       +        self.stdscr = stdscr
       +
       +        Color.setup_palette()
       +        self.setup_windows()
       +
       +        # Load some data and redraw
       +        self.fetch_next()
       +        self.selected = 0
       +        self.full_redraw()
       +
       +        self.loop()
       +
       +    def setup_windows(self):
       +        screen_height, screen_width = self.stdscr.getmaxyx()
       +
       +        if screen_width < 60:
       +            raise ConsoleError("Terminal screen is too narrow, toot curses requires at least 60 columns to display properly.")
       +
       +        left_width = max(min(screen_width // 3, 60), 30)
       +        right_width = screen_width - left_width
       +
       +        self.header = HeaderWindow(2, screen_width, 0, 0)
       +        self.footer = FooterWindow(2, screen_width, screen_height - 2, 0)
       +        self.left = StatusListWindow(screen_height - 4, left_width, 2, 0)
       +        self.right = StatusDetailWindow(screen_height - 4, right_width, 2, left_width)
       +
       +    def loop(self):
       +        while True:
       +            key = self.left.pad.getkey()
       +
       +            if key.lower() == 'q':
       +                return
       +
       +            elif key.lower() == 'v':
       +                status = self.get_selected_status()
       +                if status:
       +                    webbrowser.open(status['url'])
       +
       +            elif key.lower() == 'j' or key == 'B':
       +                self.select_next()
       +
       +            elif key.lower() == 'k' or key == 'A':
       +                self.select_previous()
       +
       +            elif key == 'KEY_RESIZE':
       +                self.setup_windows()
       +                self.full_redraw()
       +
       +    def select_previous(self):
       +        """Move to the previous status in the timeline."""
       +        self.footer.clear_message()
       +
       +        if self.selected == 0:
       +            self.footer.draw_message("Cannot move beyond first toot.", Color.GREEN)
       +            return
       +
       +        old_index = self.selected
       +        new_index = self.selected - 1
       +
       +        self.selected = new_index
       +        self.redraw_after_selection_change(old_index, new_index)
       +
       +    def select_next(self):
       +        """Move to the next status in the timeline."""
       +        self.footer.clear_message()
       +
       +        old_index = self.selected
       +        new_index = self.selected + 1
       +
       +        # Load more statuses if no more are available
       +        if self.selected + 1 >= len(self.statuses):
       +            self.fetch_next()
       +            self.left.draw_statuses(self.statuses, self.selected, new_index - 1)
       +            self.draw_footer_status()
       +
       +        self.selected = new_index
       +        self.redraw_after_selection_change(old_index, new_index)
       +
       +    def fetch_next(self):
       +        try:
       +            self.footer.draw_message("Loading toots...", Color.BLUE)
       +            statuses = next(self.status_generator)
       +        except StopIteration:
       +            return None
       +
       +        for status in statuses:
       +            self.statuses.append(parse_status(status))
       +
       +        self.footer.draw_message("Loaded {} toots".format(len(statuses)), Color.GREEN)
       +
       +        return len(statuses)
       +
       +    def full_redraw(self):
       +        """Perform a full redraw of the UI."""
       +        self.header.draw()
       +        self.draw_footer_status()
       +
       +        self.left.draw_statuses(self.statuses, self.selected)
       +        self.right.draw(self.get_selected_status())
       +        self.header.draw()
       +
       +    def redraw_after_selection_change(self, old_index, new_index):
       +        old_status = self.statuses[old_index]
       +        new_status = self.statuses[new_index]
       +
       +        # Perform a partial redraw
       +        self.left.draw_status_row(old_status, old_index, highlight=False, draw_divider=False)
       +        self.left.draw_status_row(new_status, new_index, highlight=True, draw_divider=False)
       +        self.left.scroll_if_required(new_index)
       +
       +        self.right.draw(new_status)
       +        self.draw_footer_status()
       +
       +    def get_selected_status(self):
       +        if len(self.statuses) > self.selected:
       +            return self.statuses[self.selected]
       +
       +    def draw_footer_status(self):
       +        self.footer.draw_status(self.selected, len(self.statuses))
       +
       +
       +def parse_status(status):
       +    _status = status.get('reblog') or status
       +    account = parse_account(_status['account'])
       +    lines = list(format_content(_status['content']))
       +
       +    created_at = status['created_at'][:19].split('T')
       +    boosted_by = parse_account(status['account']) if status['reblog'] else None
       +
       +    return {
       +        'account': account,
       +        'boosted_by': boosted_by,
       +        'created_at': created_at,
       +        'lines': lines,
       +        'media_attachments': _status['media_attachments'],
       +        'url': _status['url'],
       +    }
       +
       +
       +def parse_account(account):
       +    return {
       +        'id': account['id'],
       +        'acct': account['acct'],
       +        'display_name': account['display_name'],
       +    }
 (DIR) diff --git a/toot/ui/utils.py b/toot/ui/utils.py
       @@ -0,0 +1,28 @@
       +def draw_horizontal_divider(window, y):
       +    height, width = window.getmaxyx()
       +
       +    # Don't draw out of bounds
       +    if y < height - 1:
       +        line = '├' + '─' * (width - 2) + '┤'
       +        window.addstr(y, 0, line)
       +
       +
       +def enumerate_lines(generator, default_color):
       +    for y, item in enumerate(generator):
       +        if isinstance(item, tuple) and len(item) == 2:
       +            yield y, item[0], item[1]
       +        elif isinstance(item, str):
       +            yield y, item, default_color
       +        elif item is None:
       +            yield y, "", default_color
       +        else:
       +            raise ValueError("Wrong yield in generator")
       +
       +
       +def draw_lines(window, lines, x, y, default_color):
       +    height, _ = window.getmaxyx()
       +    for dy, line, color in enumerate_lines(lines, default_color):
       +        if y + dy < height - 1:
       +            window.addstr(y + dy, x, line, color)
       +
       +    return y + dy + 1
 (DIR) diff --git a/toot/utils.py b/toot/utils.py
       @@ -57,3 +57,11 @@ def domain_exists(name):
        def assert_domain_exists(domain):
            if not domain_exists(domain):
                raise ConsoleError("Domain {} not found".format(domain))
       +
       +
       +def trunc(text, length):
       +    """Trims text to given length, if trimmed appends ellipsis."""
       +    if len(text) <= length:
       +        return text
       +
       +    return text[:length - 1] + '…'