Merge pull request #39 from ihabunek/curses-resize
Improvements to the curses app
This commit is contained in:
commit
d3d69509cb
8 changed files with 460 additions and 267 deletions
35
toot/api.py
35
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'
|
||||
|
||||
while next_path:
|
||||
response = http.get(app, user, next_path)
|
||||
def _timeline_generator(app, user, path, limit=20):
|
||||
while path:
|
||||
response = http.get(app, user, path)
|
||||
yield response.json()
|
||||
next_path = _get_next_path(response.headers)
|
||||
path = get_next_path(response.headers)
|
||||
|
||||
|
||||
def _anon_timeline_generator(instance, path, limit=20):
|
||||
while path:
|
||||
url = "https://{}{}".format(instance, path)
|
||||
response = http.anon_get(url, path)
|
||||
yield response.json()
|
||||
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):
|
||||
|
|
254
toot/app.py
254
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'],
|
||||
}
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
||||
|
|
0
toot/ui/__init__.py
Normal file
0
toot/ui/__init__.py
Normal file
375
toot/ui/app.py
Normal file
375
toot/ui/app.py
Normal file
|
@ -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'],
|
||||
}
|
28
toot/ui/utils.py
Normal file
28
toot/ui/utils.py
Normal file
|
@ -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
|
|
@ -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] + '…'
|
||||
|
|
Loading…
Reference in a new issue