2017-04-21 18:23:48 +00:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
import webbrowser
|
|
|
|
|
|
|
|
from textwrap import wrap
|
|
|
|
|
2017-12-30 12:32:52 +00:00
|
|
|
from toot.exceptions import ConsoleError
|
2018-01-06 16:32:27 +00:00
|
|
|
from toot.ui.utils import draw_horizontal_divider, draw_lines
|
2018-01-04 11:36:14 +00:00
|
|
|
from toot.utils import format_content, trunc
|
2017-04-24 14:25:34 +00:00
|
|
|
|
2017-09-09 07:54:13 +00:00
|
|
|
# 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")
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
class Color:
|
2018-01-04 12:17:34 +00:00
|
|
|
@classmethod
|
|
|
|
def setup_palette(class_):
|
2018-01-06 16:32:27 +00:00
|
|
|
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 StatusDetailWindow:
|
|
|
|
"""Window which shows details of a status (right side)"""
|
|
|
|
def __init__(self, height, width, y, x):
|
|
|
|
# screen_height, screen_width = stdscr.getmaxyx()
|
|
|
|
|
|
|
|
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']
|
|
|
|
|
|
|
|
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):
|
|
|
|
if status['url'] is not None:
|
|
|
|
yield status['url']
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-06 16:32:27 +00:00
|
|
|
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()
|
|
|
|
|
|
|
|
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()
|
2018-01-04 12:13:29 +00:00
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
class TimelineApp:
|
|
|
|
def __init__(self, status_generator):
|
|
|
|
self.status_generator = status_generator
|
|
|
|
self.statuses = []
|
|
|
|
self.selected = None
|
2018-01-03 11:15:56 +00:00
|
|
|
self.stdscr = None
|
2018-01-05 16:33:06 +00:00
|
|
|
self.scroll_pos = 0
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
def run(self):
|
|
|
|
curses.wrapper(self._wrapped_run)
|
|
|
|
|
|
|
|
def _wrapped_run(self, stdscr):
|
2018-01-03 11:15:56 +00:00
|
|
|
self.stdscr = stdscr
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-03 11:15:56 +00:00
|
|
|
self.setup_windows()
|
2017-04-21 18:23:48 +00:00
|
|
|
Color.setup_palette()
|
|
|
|
|
|
|
|
# Load some data and redraw
|
|
|
|
self.fetch_next()
|
|
|
|
self.selected = 0
|
|
|
|
self.full_redraw()
|
|
|
|
|
|
|
|
self.loop()
|
|
|
|
|
2018-01-03 11:15:56 +00:00
|
|
|
def setup_windows(self):
|
|
|
|
screen_height, screen_width = self.stdscr.getmaxyx()
|
|
|
|
|
2018-01-04 11:36:14 +00:00
|
|
|
if screen_width < 60:
|
|
|
|
raise ConsoleError("Terminal screen is too narrow, toot curses requires at least 60 columns to display properly.")
|
|
|
|
|
|
|
|
self.left_width = max(min(screen_width // 3, 60), 30)
|
2018-01-03 11:15:56 +00:00
|
|
|
self.right_width = screen_width - self.left_width
|
|
|
|
|
|
|
|
self.top = curses.newwin(2, screen_width, 0, 0)
|
2018-01-04 13:16:52 +00:00
|
|
|
self.left = curses.newpad(screen_height - 4, self.left_width)
|
2018-01-06 16:32:27 +00:00
|
|
|
self.right = StatusDetailWindow(screen_height - 4, self.right_width, 2, self.left_width)
|
2018-01-03 11:15:56 +00:00
|
|
|
self.bottom = curses.newwin(2, screen_width, screen_height - 2, 0)
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
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'])
|
|
|
|
|
2018-01-05 16:43:44 +00:00
|
|
|
elif key.lower() == 'j' or key == 'B':
|
2017-04-21 18:23:48 +00:00
|
|
|
self.select_next()
|
|
|
|
|
2018-01-05 16:43:44 +00:00
|
|
|
elif key.lower() == 'k' or key == 'A':
|
2017-04-21 18:23:48 +00:00
|
|
|
self.select_previous()
|
|
|
|
|
2018-01-03 11:15:56 +00:00
|
|
|
elif key == 'KEY_RESIZE':
|
|
|
|
self.setup_windows()
|
|
|
|
self.full_redraw()
|
|
|
|
|
2018-01-05 16:33:06 +00:00
|
|
|
def scroll_to(self, index):
|
|
|
|
self.scroll_pos = index
|
|
|
|
height, width = self.stdscr.getmaxyx()
|
|
|
|
|
|
|
|
self.left.refresh(3 * index, 0, 2, 0, height - 4, self.left_width)
|
|
|
|
|
|
|
|
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_refresh(self):
|
|
|
|
self.scroll_to(self.scroll_pos)
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
def select_previous(self):
|
|
|
|
"""Move to the previous status in the timeline."""
|
2018-01-04 12:13:29 +00:00
|
|
|
self.clear_bottom_message()
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
if self.selected == 0:
|
2018-01-04 12:17:34 +00:00
|
|
|
self.draw_bottom_message("Cannot move beyond first toot.", Color.GREEN)
|
2017-04-21 18:23:48 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
old_index = self.selected
|
|
|
|
new_index = self.selected - 1
|
|
|
|
|
|
|
|
self.selected = new_index
|
|
|
|
self.redraw_after_selection_change(old_index, new_index)
|
|
|
|
|
2018-01-05 16:33:06 +00:00
|
|
|
# Scroll if required
|
|
|
|
if new_index < self.scroll_pos:
|
|
|
|
self.scroll_up()
|
|
|
|
else:
|
|
|
|
self.scroll_refresh()
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
def select_next(self):
|
|
|
|
"""Move to the next status in the timeline."""
|
2018-01-04 12:13:29 +00:00
|
|
|
self.clear_bottom_message()
|
|
|
|
|
2018-01-05 16:33:06 +00:00
|
|
|
# Load more statuses if no more are available
|
2017-04-21 18:23:48 +00:00
|
|
|
if self.selected + 1 >= len(self.statuses):
|
2018-01-04 13:16:52 +00:00
|
|
|
self.fetch_next()
|
|
|
|
self.draw_statuses(self.left, self.selected + 1)
|
|
|
|
self.draw_bottom_status()
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
old_index = self.selected
|
|
|
|
new_index = self.selected + 1
|
|
|
|
|
|
|
|
self.selected = new_index
|
|
|
|
self.redraw_after_selection_change(old_index, new_index)
|
|
|
|
|
2018-01-05 16:33:06 +00:00
|
|
|
# Scroll if required
|
|
|
|
if new_index >= self.scroll_pos + self.get_page_size():
|
|
|
|
self.scroll_down()
|
|
|
|
else:
|
|
|
|
self.scroll_refresh()
|
|
|
|
|
|
|
|
def get_page_size(self):
|
|
|
|
"""Calculate how many statuses fit on one page (3 lines per status)"""
|
2018-01-06 16:32:27 +00:00
|
|
|
height = self.right.window.getmaxyx()[0] - 2 # window height - borders
|
2018-01-05 16:33:06 +00:00
|
|
|
return height // 3
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
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
|
2018-01-05 16:33:06 +00:00
|
|
|
self.draw_status_row(self.left, old_status, old_index, False)
|
|
|
|
self.draw_status_row(self.left, new_status, new_index, True)
|
2018-01-06 16:32:27 +00:00
|
|
|
self.right.draw(new_status)
|
2018-01-04 13:16:52 +00:00
|
|
|
self.draw_bottom_status()
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
def fetch_next(self):
|
|
|
|
try:
|
2018-01-04 13:16:52 +00:00
|
|
|
self.draw_bottom_message("Loading toots...", Color.BLUE)
|
2017-04-26 09:25:12 +00:00
|
|
|
statuses = next(self.status_generator)
|
2017-04-21 18:23:48 +00:00
|
|
|
except StopIteration:
|
|
|
|
return None
|
|
|
|
|
|
|
|
for status in statuses:
|
|
|
|
self.statuses.append(parse_status(status))
|
|
|
|
|
2018-01-04 13:16:52 +00:00
|
|
|
self.draw_bottom_message("Loaded {} toots".format(len(statuses)), Color.GREEN)
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
return len(statuses)
|
|
|
|
|
|
|
|
def full_redraw(self):
|
|
|
|
"""Perform a full redraw of the UI."""
|
|
|
|
self.left.clear()
|
|
|
|
self.top.clear()
|
|
|
|
self.bottom.clear()
|
|
|
|
|
|
|
|
self.left.box()
|
|
|
|
|
2018-01-04 12:17:34 +00:00
|
|
|
self.top.addstr(" toot - your Mastodon command line interface\n", Color.YELLOW)
|
2017-04-21 18:23:48 +00:00
|
|
|
self.top.addstr(" https://github.com/ihabunek/toot")
|
|
|
|
|
|
|
|
self.draw_statuses(self.left)
|
2018-01-06 16:32:27 +00:00
|
|
|
self.right.draw(self.get_selected_status())
|
2017-04-21 18:23:48 +00:00
|
|
|
self.draw_usage(self.bottom)
|
2018-01-04 13:16:52 +00:00
|
|
|
self.draw_bottom_status()
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-05 16:33:06 +00:00
|
|
|
self.scroll_refresh()
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
self.top.refresh()
|
|
|
|
self.bottom.refresh()
|
|
|
|
|
|
|
|
def draw_usage(self, window):
|
|
|
|
# Show usage on the bottom
|
|
|
|
window.addstr("Usage: | ")
|
2018-01-04 12:17:34 +00:00
|
|
|
window.addch("j", Color.GREEN)
|
2017-04-21 18:23:48 +00:00
|
|
|
window.addstr(" next | ")
|
2018-01-04 12:17:34 +00:00
|
|
|
window.addch("k", Color.GREEN)
|
2017-04-21 18:23:48 +00:00
|
|
|
window.addstr(" previous | ")
|
2018-01-04 12:17:34 +00:00
|
|
|
window.addch("v", Color.GREEN)
|
2017-04-21 18:23:48 +00:00
|
|
|
window.addstr(" open in browser | ")
|
2018-01-04 12:17:34 +00:00
|
|
|
window.addch("q", Color.GREEN)
|
2017-04-21 18:23:48 +00:00
|
|
|
window.addstr(" quit")
|
|
|
|
|
|
|
|
window.refresh()
|
|
|
|
|
|
|
|
def get_selected_status(self):
|
|
|
|
if len(self.statuses) > self.selected:
|
|
|
|
return self.statuses[self.selected]
|
|
|
|
|
2018-01-05 16:33:06 +00:00
|
|
|
def draw_status_row(self, window, status, index, highlight=False):
|
|
|
|
offset = 3 * index
|
|
|
|
|
2018-01-04 11:36:14 +00:00
|
|
|
height, width = window.getmaxyx()
|
2018-01-04 12:17:34 +00:00
|
|
|
color = Color.BLUE if highlight else 0
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
date, time = status['created_at']
|
2018-01-05 16:33:06 +00:00
|
|
|
window.addstr(offset + 1, 2, date, color)
|
|
|
|
window.addstr(offset + 2, 2, time, color)
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-04 11:36:14 +00:00
|
|
|
trunc_width = width - 16
|
2018-01-05 16:33:06 +00:00
|
|
|
acct = trunc(status['account']['acct'], trunc_width).ljust(trunc_width)
|
|
|
|
display_name = trunc(status['account']['display_name'], trunc_width).ljust(trunc_width)
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-05 16:33:06 +00:00
|
|
|
window.addstr(offset + 1, 14, acct, color)
|
|
|
|
window.addstr(offset + 2, 14, display_name, color)
|
|
|
|
window.addstr(offset + 3, 1, '─' * (width - 2))
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-03 11:15:56 +00:00
|
|
|
screen_height, screen_width = self.stdscr.getmaxyx()
|
|
|
|
window.refresh(0, 0, 2, 0, screen_height - 4, self.left_width)
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-04 13:16:52 +00:00
|
|
|
def draw_statuses(self, window, starting=0):
|
|
|
|
# Resize window to accomodate statuses if required
|
|
|
|
height, width = window.getmaxyx()
|
|
|
|
new_height = len(self.statuses) * 3 + 1
|
|
|
|
if new_height > height:
|
|
|
|
window.resize(new_height, width)
|
|
|
|
window.box()
|
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
for index, status in enumerate(self.statuses):
|
2018-01-04 13:16:52 +00:00
|
|
|
if index >= starting:
|
|
|
|
highlight = self.selected == index
|
2018-01-05 16:33:06 +00:00
|
|
|
self.draw_status_row(window, status, index, highlight)
|
2017-04-21 18:23:48 +00:00
|
|
|
|
2018-01-04 12:13:29 +00:00
|
|
|
def clear_bottom_message(self):
|
|
|
|
_, width = self.bottom.getmaxyx()
|
|
|
|
self.bottom.addstr(1, 0, " " * (width - 1))
|
|
|
|
self.bottom.refresh()
|
|
|
|
|
|
|
|
def draw_bottom_message(self, text, color=0):
|
|
|
|
_, width = self.bottom.getmaxyx()
|
2018-01-04 13:16:52 +00:00
|
|
|
text = trunc(text, width - 1).ljust(width - 1)
|
2018-01-04 12:13:29 +00:00
|
|
|
self.bottom.addstr(1, 0, text, color)
|
|
|
|
self.bottom.refresh()
|
|
|
|
|
2018-01-04 13:16:52 +00:00
|
|
|
def draw_bottom_status(self):
|
2018-01-04 12:13:29 +00:00
|
|
|
_, width = self.bottom.getmaxyx()
|
2018-01-04 13:16:52 +00:00
|
|
|
text = "Showing toot {} of {}".format(self.selected + 1, len(self.statuses))
|
2018-01-04 12:13:29 +00:00
|
|
|
text = trunc(text, width - 1).ljust(width - 1)
|
2018-01-04 13:16:52 +00:00
|
|
|
self.bottom.addstr(0, 0, text, Color.WHITE_ON_BLUE | curses.A_BOLD)
|
|
|
|
self.bottom.refresh()
|
2018-01-04 12:13:29 +00:00
|
|
|
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
def parse_status(status):
|
2017-04-24 14:25:34 +00:00
|
|
|
_status = status.get('reblog') or status
|
|
|
|
account = parse_account(_status['account'])
|
|
|
|
lines = list(format_content(_status['content']))
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
created_at = status['created_at'][:19].split('T')
|
2017-04-24 14:25:34 +00:00
|
|
|
boosted_by = parse_account(status['account']) if status['reblog'] else None
|
2017-04-21 18:23:48 +00:00
|
|
|
|
|
|
|
return {
|
2017-04-24 14:25:34 +00:00
|
|
|
'account': account,
|
2017-04-21 18:23:48 +00:00
|
|
|
'boosted_by': boosted_by,
|
2017-04-24 14:25:34 +00:00
|
|
|
'created_at': created_at,
|
2017-04-21 18:23:48 +00:00
|
|
|
'lines': lines,
|
2017-04-24 14:25:34 +00:00
|
|
|
'media_attachments': _status['media_attachments'],
|
2018-01-06 16:26:29 +00:00
|
|
|
'url': _status['url'],
|
2017-04-21 18:23:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def parse_account(account):
|
|
|
|
return {
|
|
|
|
'id': account['id'],
|
|
|
|
'acct': account['acct'],
|
|
|
|
'display_name': account['display_name'],
|
|
|
|
}
|