mirror of
https://github.com/ihabunek/toot.git
synced 2024-09-29 04:35:54 -04:00
Remove curses app, replaced by tui
This commit is contained in:
parent
103bc43c26
commit
62d7855fc0
2
Makefile
2
Makefile
@ -1,4 +1,4 @@
|
|||||||
.PHONY: clean publish test
|
.PHONY: clean publish test docs
|
||||||
|
|
||||||
dist :
|
dist :
|
||||||
python setup.py sdist --formats=gztar,zip
|
python setup.py sdist --formats=gztar,zip
|
||||||
|
@ -36,7 +36,7 @@ Features
|
|||||||
Terminal User Interface
|
Terminal User Interface
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
toot includes a curses-based terminal user interface (TUI). Run it with ``toot tui``.
|
toot includes a terminal user interface (TUI). Run it with ``toot tui``.
|
||||||
|
|
||||||
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_list.png
|
.. image :: https://raw.githubusercontent.com/ihabunek/toot/master/docs/_static/tui_list.png
|
||||||
|
|
||||||
|
2
setup.py
2
setup.py
@ -35,7 +35,7 @@ setup(
|
|||||||
'Programming Language :: Python :: 3.6',
|
'Programming Language :: Python :: 3.6',
|
||||||
'Programming Language :: Python :: 3.7',
|
'Programming Language :: Python :: 3.7',
|
||||||
],
|
],
|
||||||
packages=['toot', 'toot.ui', 'toot.tui'],
|
packages=['toot', 'toot.tui'],
|
||||||
python_requires=">=3.4",
|
python_requires=">=3.4",
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"requests>=2.13,<3.0",
|
"requests>=2.13,<3.0",
|
||||||
|
@ -75,12 +75,6 @@ def thread(app, user, args):
|
|||||||
print_timeline(thread)
|
print_timeline(thread)
|
||||||
|
|
||||||
|
|
||||||
def curses(app, user, args):
|
|
||||||
generator = get_timeline_generator(app, user, args)
|
|
||||||
from toot.ui.app import TimelineApp
|
|
||||||
TimelineApp(app, user, generator).run()
|
|
||||||
|
|
||||||
|
|
||||||
def post(app, user, args):
|
def post(app, user, args):
|
||||||
# TODO: this might be achievable, explore options
|
# TODO: this might be achievable, explore options
|
||||||
if args.editor and not sys.stdin.isatty():
|
if args.editor and not sys.stdin.isatty():
|
||||||
|
@ -155,14 +155,6 @@ timeline_args = common_timeline_args + [
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
curses_args = common_timeline_args + [
|
|
||||||
(["-c", "--count"], {
|
|
||||||
"type": timeline_count,
|
|
||||||
"help": "number of toots to show per page (1-20, default 20).",
|
|
||||||
"default": 20,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
AUTH_COMMANDS = [
|
AUTH_COMMANDS = [
|
||||||
Command(
|
Command(
|
||||||
name="login",
|
name="login",
|
||||||
@ -203,12 +195,6 @@ TUI_COMMANDS = [
|
|||||||
arguments=[],
|
arguments=[],
|
||||||
require_auth=True,
|
require_auth=True,
|
||||||
),
|
),
|
||||||
Command(
|
|
||||||
name="curses",
|
|
||||||
description="An experimental timeline app (DEPRECATED, use 'toot tui' instead)",
|
|
||||||
arguments=curses_args,
|
|
||||||
require_auth=False,
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
This curses TUI has been deprecated in favour of the new one in `toot.tui` which
|
|
||||||
uses the Urwid framework to avoid having to do all the drawing manually.
|
|
||||||
|
|
||||||
This app will no longer be maintained and will be removed in a future release.
|
|
789
toot/ui/app.py
789
toot/ui/app.py
@ -1,789 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
import os
|
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
from toot import __version__, api
|
|
||||||
|
|
||||||
from toot.exceptions import ConsoleError
|
|
||||||
from toot.ui.parsers import parse_status
|
|
||||||
from toot.ui.utils import draw_horizontal_divider, draw_lines, size_as_drawn
|
|
||||||
from toot.wcstring import fit_text
|
|
||||||
|
|
||||||
# Attempt to load curses, which is not available on windows
|
|
||||||
try:
|
|
||||||
import curses
|
|
||||||
import curses.panel
|
|
||||||
import curses.textpad
|
|
||||||
except ImportError:
|
|
||||||
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_CYAN, curses.COLOR_BLACK)
|
|
||||||
curses.init_pair(7, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
|
|
||||||
curses.init_pair(8, curses.COLOR_WHITE, curses.COLOR_BLUE)
|
|
||||||
curses.init_pair(9, curses.COLOR_WHITE, curses.COLOR_RED)
|
|
||||||
|
|
||||||
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_.CYAN = curses.color_pair(6)
|
|
||||||
class_.MAGENTA = curses.color_pair(7)
|
|
||||||
class_.WHITE_ON_BLUE = curses.color_pair(8)
|
|
||||||
class_.WHITE_ON_RED = curses.color_pair(9)
|
|
||||||
|
|
||||||
class_.HASHTAG = class_.BLUE | curses.A_BOLD
|
|
||||||
|
|
||||||
|
|
||||||
class HeaderWindow:
|
|
||||||
def __init__(self, stdscr, height, width, y, x):
|
|
||||||
self.window = stdscr.subwin(height, width, y, x)
|
|
||||||
self.window.bkgdset(' ', Color.WHITE_ON_BLUE)
|
|
||||||
self.height = height
|
|
||||||
self.width = width
|
|
||||||
|
|
||||||
def draw(self, user):
|
|
||||||
username = "{}@{}".format(user.username, user.instance)
|
|
||||||
|
|
||||||
self.window.erase()
|
|
||||||
self.window.addstr(" toot", curses.A_BOLD)
|
|
||||||
self.window.addstr(" | ")
|
|
||||||
self.window.addstr(username)
|
|
||||||
self.window.addstr(" | ")
|
|
||||||
self.window.refresh()
|
|
||||||
|
|
||||||
|
|
||||||
class FooterWindow:
|
|
||||||
def __init__(self, stdscr, height, width, y, x):
|
|
||||||
self.window = stdscr.subwin(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 = fit_text(text, self.width)
|
|
||||||
self.window.addstr(0, 0, text, Color.WHITE_ON_BLUE | curses.A_BOLD)
|
|
||||||
self.window.refresh()
|
|
||||||
|
|
||||||
def draw_message(self, text, color):
|
|
||||||
text = fit_text(text, 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, stdscr, 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()
|
|
||||||
|
|
||||||
# Make curses interpret escape sequences for getch (why is this off by default?)
|
|
||||||
self.pad.keypad(True)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
trunc_width = width - 15
|
|
||||||
acct = fit_text("@" + status['account']['acct'], trunc_width)
|
|
||||||
display_name = fit_text(status['account']['display_name'], 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 status['in_reply_to_id'] is not None:
|
|
||||||
self.pad.addstr(offset + 1, width - 3, '⤶', Color.CYAN)
|
|
||||||
|
|
||||||
date, time = status['created_at']
|
|
||||||
self.pad.addstr(offset + 1, 1, " " + date.ljust(12), color)
|
|
||||||
self.pad.addstr(offset + 2, 1, " " + time.ljust(12), color)
|
|
||||||
|
|
||||||
if status['favourited']:
|
|
||||||
self.pad.addstr(offset + 2, width - 3, '⭐', Color.YELLOW)
|
|
||||||
|
|
||||||
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, stdscr, height, width, y, x):
|
|
||||||
self.window = stdscr.subwin(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
|
|
||||||
|
|
||||||
if status['sensitive']:
|
|
||||||
for line in status['spoiler_text']:
|
|
||||||
yield line
|
|
||||||
yield
|
|
||||||
|
|
||||||
if status['sensitive'] and not status['show_sensitive']:
|
|
||||||
yield "Marked as sensitive, press s to view".ljust(text_width), Color.WHITE_ON_RED
|
|
||||||
return
|
|
||||||
|
|
||||||
for line in status['content']:
|
|
||||||
yield line
|
|
||||||
|
|
||||||
if status['media_attachments']:
|
|
||||||
yield
|
|
||||||
yield "Media:"
|
|
||||||
for attachment in status['media_attachments']:
|
|
||||||
yield attachment['text_url'] or attachment['url']
|
|
||||||
|
|
||||||
def footer_lines(self, status):
|
|
||||||
if status['url'] is not None:
|
|
||||||
yield status['url']
|
|
||||||
|
|
||||||
if status['boosted_by']:
|
|
||||||
acct = status['boosted_by']['acct']
|
|
||||||
yield "Boosted by @{}".format(acct), Color.GREEN
|
|
||||||
|
|
||||||
if status['reblogged']:
|
|
||||||
yield "↷ Boosted", Color.CYAN
|
|
||||||
|
|
||||||
yield (
|
|
||||||
"{replies_count} replies, "
|
|
||||||
"{reblogs_count} reblogs, "
|
|
||||||
"{favourites_count} favourites"
|
|
||||||
).format(**status), Color.CYAN
|
|
||||||
|
|
||||||
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, 1, 2, Color.WHITE)
|
|
||||||
draw_horizontal_divider(self.window, y)
|
|
||||||
draw_lines(self.window, footer, y + 1, 2, Color.WHITE)
|
|
||||||
|
|
||||||
self.window.refresh()
|
|
||||||
|
|
||||||
|
|
||||||
class Modal:
|
|
||||||
def __init__(self, stdscr, resize_callback=None):
|
|
||||||
self.stdscr = stdscr
|
|
||||||
self.resize_callback = resize_callback
|
|
||||||
|
|
||||||
self.setup_windows()
|
|
||||||
self.full_redraw()
|
|
||||||
self.panel = curses.panel.new_panel(self.window)
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
def get_content(self):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def get_size_pos(self, stdscr):
|
|
||||||
screen_height, screen_width = stdscr.getmaxyx()
|
|
||||||
|
|
||||||
content = self.get_content()
|
|
||||||
height = len(content) + 2
|
|
||||||
width = max(len(l) for l in content) + 4
|
|
||||||
|
|
||||||
y = (screen_height - height) // 2
|
|
||||||
x = (screen_width - width) // 2
|
|
||||||
|
|
||||||
return height, width, y, x
|
|
||||||
|
|
||||||
def setup_windows(self):
|
|
||||||
height, width, y, x = self.get_size_pos(self.stdscr)
|
|
||||||
self.window = curses.newwin(height, width, y, x)
|
|
||||||
|
|
||||||
def full_redraw(self):
|
|
||||||
self.setup_windows()
|
|
||||||
self.window.box()
|
|
||||||
draw_lines(self.window, self.get_content(), 1, 2, Color.WHITE)
|
|
||||||
|
|
||||||
def show(self):
|
|
||||||
self.panel.top()
|
|
||||||
self.panel.show()
|
|
||||||
self.window.refresh()
|
|
||||||
curses.panel.update_panels()
|
|
||||||
|
|
||||||
def hide(self):
|
|
||||||
self.panel.hide()
|
|
||||||
curses.panel.update_panels()
|
|
||||||
|
|
||||||
def loop(self):
|
|
||||||
self.show()
|
|
||||||
|
|
||||||
while True:
|
|
||||||
ch = self.window.getch()
|
|
||||||
key = chr(ch).lower() if curses.ascii.isprint(ch) else None
|
|
||||||
|
|
||||||
if key == 'q':
|
|
||||||
break
|
|
||||||
elif ch == curses.KEY_RESIZE:
|
|
||||||
if self.resize_callback:
|
|
||||||
self.resize_callback()
|
|
||||||
self.full_redraw()
|
|
||||||
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
|
|
||||||
class HelpModal(Modal):
|
|
||||||
def get_content(self):
|
|
||||||
return [
|
|
||||||
("toot v{}".format(__version__), Color.GREEN | curses.A_BOLD),
|
|
||||||
"",
|
|
||||||
"Key bindings:",
|
|
||||||
"",
|
|
||||||
" h - show help",
|
|
||||||
" j or ↓ - move down",
|
|
||||||
" k or ↑ - move up",
|
|
||||||
" v - view current toot in browser",
|
|
||||||
" b - toggle boost status",
|
|
||||||
" f - toggle favourite status",
|
|
||||||
" c - post a new status",
|
|
||||||
" r - reply to status",
|
|
||||||
" q - quit application",
|
|
||||||
" s - show sensitive content"
|
|
||||||
"",
|
|
||||||
"Press q to exit help.",
|
|
||||||
"",
|
|
||||||
("https://github.com/ihabunek/toot", Color.YELLOW),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class DeprecationNoticeModal(Modal):
|
|
||||||
def get_content(self):
|
|
||||||
return [
|
|
||||||
("DEPRECATION NOTICE", Color.RED | curses.A_BOLD),
|
|
||||||
"",
|
|
||||||
"This experimental terminal UI has been deprecated and will be ",
|
|
||||||
"removed in the near future.",
|
|
||||||
"",
|
|
||||||
"The new TUI can be lauched by running `toot tui`. This new UI ",
|
|
||||||
"contains all the functionality of this one and much more. ",
|
|
||||||
"It will be supported for the forseeable future.",
|
|
||||||
"",
|
|
||||||
"For details see:",
|
|
||||||
("https://github.com/ihabunek/toot/pull/108", Color.CYAN),
|
|
||||||
"",
|
|
||||||
("Press q to close this notice.", Color.YELLOW),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class EntryModal(Modal):
|
|
||||||
def __init__(self, stdscr, title, footer=None, size=(None, None), default=None, resize_callback=None):
|
|
||||||
self.stdscr = stdscr
|
|
||||||
self.resize_callback = resize_callback
|
|
||||||
self.content = [] if default is None else default.split()
|
|
||||||
self.cursor_pos = 0
|
|
||||||
self.pad_y, self.pad_x = 2, 2
|
|
||||||
|
|
||||||
self.title = title
|
|
||||||
self.footer = footer
|
|
||||||
self.size = size
|
|
||||||
if self.footer:
|
|
||||||
self.pad_y += 1
|
|
||||||
|
|
||||||
self.setup_windows()
|
|
||||||
self.full_redraw()
|
|
||||||
self.panel = curses.panel.new_panel(self.window)
|
|
||||||
self.hide()
|
|
||||||
|
|
||||||
def get_size_pos(self, stdscr):
|
|
||||||
screen_height, screen_width = stdscr.getmaxyx()
|
|
||||||
if self.size[0]:
|
|
||||||
height = self.size[0] + (self.pad_y * 2) + 1
|
|
||||||
else:
|
|
||||||
height = int(screen_height / 1.33)
|
|
||||||
if self.size[1]:
|
|
||||||
width = self.size[1] + (self.pad_x * 2) + 1
|
|
||||||
else:
|
|
||||||
width = int(screen_width / 1.25)
|
|
||||||
|
|
||||||
y = (screen_height - height) // 2
|
|
||||||
x = (screen_width - width) // 2
|
|
||||||
|
|
||||||
return height, width, y, x
|
|
||||||
|
|
||||||
def setup_windows(self):
|
|
||||||
height, width, y, x = self.get_size_pos(self.stdscr)
|
|
||||||
|
|
||||||
self.window = curses.newwin(height, width, y, x)
|
|
||||||
self.text_window = self.window.derwin(height - (self.pad_y * 2), width - (self.pad_x * 2), self.pad_y, self.pad_x)
|
|
||||||
self.text_window.keypad(True)
|
|
||||||
|
|
||||||
def full_redraw(self):
|
|
||||||
self.window.erase()
|
|
||||||
self.window.box()
|
|
||||||
|
|
||||||
draw_lines(self.window, ["{} (^D to confirm):".format(self.title)], 1, 2, Color.WHITE)
|
|
||||||
if self.footer:
|
|
||||||
window_height, window_width = self.window.getmaxyx()
|
|
||||||
draw_lines(self.window, [self.footer], window_height - self.pad_y + 1, 2, Color.WHITE)
|
|
||||||
|
|
||||||
self.window.refresh()
|
|
||||||
self.refresh_text()
|
|
||||||
|
|
||||||
def refresh_text(self):
|
|
||||||
text = self.get_content()
|
|
||||||
lines = text.split('\n')
|
|
||||||
draw_lines(self.text_window, lines, 0, 0, Color.WHITE)
|
|
||||||
|
|
||||||
text_window_height, text_window_width = self.text_window.getmaxyx()
|
|
||||||
text_on_screen = (''.join(self.content)[:self.cursor_pos] + '_').split('\n')
|
|
||||||
y, x = size_as_drawn(text_on_screen, text_window_width)
|
|
||||||
self.text_window.move(y, x)
|
|
||||||
|
|
||||||
def show(self):
|
|
||||||
super().show()
|
|
||||||
self.refresh_text()
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
self.content = []
|
|
||||||
self.cursor_pos = 0
|
|
||||||
|
|
||||||
def on_resize(self):
|
|
||||||
if self.resize_callback:
|
|
||||||
self.resize_callback()
|
|
||||||
self.setup_windows()
|
|
||||||
self.full_redraw()
|
|
||||||
|
|
||||||
def do_command(self, ch):
|
|
||||||
if curses.ascii.isprint(ch) or ch == curses.ascii.LF:
|
|
||||||
text_window_height, text_window_width = self.text_window.getmaxyx()
|
|
||||||
y, x = size_as_drawn((self.get_content() + chr(ch)).split('\n'), text_window_width)
|
|
||||||
if y < text_window_height - 1 and x < text_window_width:
|
|
||||||
self.content.insert(self.cursor_pos, chr(ch))
|
|
||||||
self.cursor_pos += 1
|
|
||||||
else:
|
|
||||||
curses.beep()
|
|
||||||
|
|
||||||
elif ch == curses.KEY_BACKSPACE:
|
|
||||||
if self.cursor_pos > 0:
|
|
||||||
del self.content[self.cursor_pos - 1]
|
|
||||||
self.cursor_pos -= 1
|
|
||||||
else:
|
|
||||||
curses.beep()
|
|
||||||
|
|
||||||
elif ch == curses.KEY_DC:
|
|
||||||
if self.cursor_pos >= 0 and self.cursor_pos < len(self.content):
|
|
||||||
del self.content[self.cursor_pos]
|
|
||||||
else:
|
|
||||||
curses.beep()
|
|
||||||
|
|
||||||
elif ch == curses.KEY_LEFT:
|
|
||||||
if self.cursor_pos > 0:
|
|
||||||
self.cursor_pos -= 1
|
|
||||||
else:
|
|
||||||
curses.beep()
|
|
||||||
|
|
||||||
elif ch == curses.KEY_RIGHT:
|
|
||||||
if self.cursor_pos + 1 <= len(self.content):
|
|
||||||
self.cursor_pos += 1
|
|
||||||
else:
|
|
||||||
curses.beep()
|
|
||||||
|
|
||||||
elif ch in (curses.ascii.EOT, curses.ascii.RS): # ^D or (for some terminals) Ctrl+Enter
|
|
||||||
return False, False
|
|
||||||
|
|
||||||
elif ch == curses.ascii.ESC:
|
|
||||||
self.clear()
|
|
||||||
return False, True
|
|
||||||
|
|
||||||
elif ch == curses.KEY_RESIZE:
|
|
||||||
self.on_resize()
|
|
||||||
return True, False
|
|
||||||
|
|
||||||
self.refresh_text()
|
|
||||||
return True, False
|
|
||||||
|
|
||||||
def get_content(self):
|
|
||||||
return ''.join(self.content)
|
|
||||||
|
|
||||||
def loop(self):
|
|
||||||
self.show()
|
|
||||||
while True:
|
|
||||||
ch = self.text_window.getch()
|
|
||||||
if not ch:
|
|
||||||
continue
|
|
||||||
should_continue, abort_flag = self.do_command(ch)
|
|
||||||
if not should_continue:
|
|
||||||
break
|
|
||||||
self.hide()
|
|
||||||
if abort_flag:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return self.get_content()
|
|
||||||
|
|
||||||
|
|
||||||
class ComposeModal(EntryModal):
|
|
||||||
def __init__(self, stdscr, default_cw=None, **kwargs):
|
|
||||||
super().__init__(stdscr, title="Compose a toot", footer="^D to submit, ESC to quit, ^W to mark sensitive (cw)", **kwargs)
|
|
||||||
self.cw = default_cw
|
|
||||||
self.cwmodal = EntryModal(stdscr, title="Content warning", size=(1, 60), default=self.cw, resize_callback=self.on_resize)
|
|
||||||
|
|
||||||
def do_command(self, ch):
|
|
||||||
if ch == curses.ascii.ctrl(ord('w')):
|
|
||||||
self.cwmodal.on_resize()
|
|
||||||
self.cw = self.cwmodal.loop() or None
|
|
||||||
self.full_redraw()
|
|
||||||
return True, False
|
|
||||||
else:
|
|
||||||
return super().do_command(ch)
|
|
||||||
|
|
||||||
def loop(self):
|
|
||||||
content = super().loop()
|
|
||||||
return content, self.cw
|
|
||||||
|
|
||||||
|
|
||||||
class TimelineApp:
|
|
||||||
def __init__(self, app, user, status_generator):
|
|
||||||
self.app = app
|
|
||||||
self.user = user
|
|
||||||
self.status_generator = status_generator
|
|
||||||
self.statuses = []
|
|
||||||
self.stdscr = None
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
os.environ.setdefault('ESCDELAY', '25')
|
|
||||||
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.deprecation_modal.loop()
|
|
||||||
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.")
|
|
||||||
|
|
||||||
header_height = 1
|
|
||||||
footer_height = 2
|
|
||||||
footer_top = screen_height - footer_height
|
|
||||||
|
|
||||||
left_width = max(min(screen_width // 3, 60), 30)
|
|
||||||
main_height = screen_height - header_height - footer_height
|
|
||||||
main_width = screen_width - left_width
|
|
||||||
|
|
||||||
self.header = HeaderWindow(self.stdscr, header_height, screen_width, 0, 0)
|
|
||||||
self.footer = FooterWindow(self.stdscr, footer_height, screen_width, footer_top, 0)
|
|
||||||
self.left = StatusListWindow(self.stdscr, main_height, left_width, header_height, 0)
|
|
||||||
self.right = StatusDetailWindow(self.stdscr, main_height, main_width, header_height, left_width)
|
|
||||||
|
|
||||||
self.help_modal = HelpModal(self.stdscr, resize_callback=self.on_resize)
|
|
||||||
self.deprecation_modal = DeprecationNoticeModal(self.stdscr, resize_callback=self.on_resize)
|
|
||||||
|
|
||||||
def loop(self):
|
|
||||||
while True:
|
|
||||||
ch = self.left.pad.getch()
|
|
||||||
key = chr(ch).lower() if curses.ascii.isprint(ch) else None
|
|
||||||
|
|
||||||
if key == 'q':
|
|
||||||
return
|
|
||||||
|
|
||||||
elif key == 'h':
|
|
||||||
self.help_modal.loop()
|
|
||||||
self.full_redraw()
|
|
||||||
|
|
||||||
elif key == 'v':
|
|
||||||
status = self.get_selected_status()
|
|
||||||
if status:
|
|
||||||
webbrowser.open(status['url'])
|
|
||||||
|
|
||||||
elif key == 'j' or ch == curses.KEY_DOWN:
|
|
||||||
self.select_next()
|
|
||||||
|
|
||||||
elif key == 'k' or ch == curses.KEY_UP:
|
|
||||||
self.select_previous()
|
|
||||||
|
|
||||||
elif key == 's':
|
|
||||||
self.show_sensitive()
|
|
||||||
|
|
||||||
elif key == 'b':
|
|
||||||
self.toggle_reblog()
|
|
||||||
|
|
||||||
elif key == 'f':
|
|
||||||
self.toggle_favourite()
|
|
||||||
|
|
||||||
elif key == 'c':
|
|
||||||
self.compose()
|
|
||||||
|
|
||||||
elif key == 'r':
|
|
||||||
self.reply()
|
|
||||||
|
|
||||||
elif ch == curses.KEY_RESIZE:
|
|
||||||
self.on_resize()
|
|
||||||
|
|
||||||
def show_sensitive(self):
|
|
||||||
status = self.get_selected_status()
|
|
||||||
if status['sensitive'] and not status['show_sensitive']:
|
|
||||||
status['show_sensitive'] = True
|
|
||||||
self.right.draw(status)
|
|
||||||
|
|
||||||
def compose(self):
|
|
||||||
"""Compose and submit a new status"""
|
|
||||||
app, user = self.app, self.user
|
|
||||||
if not app or not user:
|
|
||||||
self.footer.draw_message("You must be logged in to post", Color.RED)
|
|
||||||
return
|
|
||||||
|
|
||||||
compose_modal = ComposeModal(self.stdscr, resize_callback=self.on_resize)
|
|
||||||
content, cw = compose_modal.loop()
|
|
||||||
self.full_redraw()
|
|
||||||
if content is None:
|
|
||||||
return
|
|
||||||
elif len(content) == 0:
|
|
||||||
self.footer.draw_message("Status must contain content", Color.RED)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.footer.draw_message("Submitting status...", Color.YELLOW)
|
|
||||||
response = api.post_status(app, user, content, spoiler_text=cw, sensitive=cw is not None)
|
|
||||||
status = parse_status(response)
|
|
||||||
self.statuses.insert(0, status)
|
|
||||||
self.selected += 1
|
|
||||||
self.left.draw_statuses(self.statuses, self.selected)
|
|
||||||
self.footer.draw_message("✓ Status posted", Color.GREEN)
|
|
||||||
|
|
||||||
def reply(self):
|
|
||||||
"""Reply to the selected status"""
|
|
||||||
status = self.get_selected_status()
|
|
||||||
app, user = self.app, self.user
|
|
||||||
if not app or not user:
|
|
||||||
self.footer.draw_message("You must be logged in to reply", Color.RED)
|
|
||||||
return
|
|
||||||
|
|
||||||
compose_modal = ComposeModal(self.stdscr, default_cw='\n'.join(status['spoiler_text']) or None, resize_callback=self.on_resize)
|
|
||||||
content, cw = compose_modal.loop()
|
|
||||||
self.full_redraw()
|
|
||||||
if content is None:
|
|
||||||
return
|
|
||||||
elif len(content) == 0:
|
|
||||||
self.footer.draw_message("Status must contain content", Color.RED)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.footer.draw_message("Submitting reply...", Color.YELLOW)
|
|
||||||
response = api.post_status(app, user, content, spoiler_text=cw, sensitive=cw is not None, in_reply_to_id=status['id'])
|
|
||||||
status = parse_status(response)
|
|
||||||
self.statuses.insert(0, status)
|
|
||||||
self.selected += 1
|
|
||||||
self.left.draw_statuses(self.statuses, self.selected)
|
|
||||||
self.footer.draw_message("✓ Reply posted", Color.GREEN)
|
|
||||||
|
|
||||||
def toggle_reblog(self):
|
|
||||||
"""Reblog or unreblog selected status."""
|
|
||||||
status = self.get_selected_status()
|
|
||||||
assert status
|
|
||||||
app, user = self.app, self.user
|
|
||||||
if not app or not user:
|
|
||||||
self.footer.draw_message("You must be logged in to reblog", Color.RED)
|
|
||||||
return
|
|
||||||
status_id = status['id']
|
|
||||||
if status['reblogged']:
|
|
||||||
status['reblogged'] = False
|
|
||||||
self.footer.draw_message("Unboosting status...", Color.YELLOW)
|
|
||||||
api.unreblog(app, user, status_id)
|
|
||||||
self.footer.draw_message("✓ Status unboosted", Color.GREEN)
|
|
||||||
else:
|
|
||||||
status['reblogged'] = True
|
|
||||||
self.footer.draw_message("Boosting status...", Color.YELLOW)
|
|
||||||
api.reblog(app, user, status_id)
|
|
||||||
self.footer.draw_message("✓ Status boosted", Color.GREEN)
|
|
||||||
|
|
||||||
self.right.draw(status)
|
|
||||||
|
|
||||||
def toggle_favourite(self):
|
|
||||||
"""Favourite or unfavourite selected status."""
|
|
||||||
status = self.get_selected_status()
|
|
||||||
assert status
|
|
||||||
app, user = self.app, self.user
|
|
||||||
if not app or not user:
|
|
||||||
self.footer.draw_message("You must be logged in to favourite", Color.RED)
|
|
||||||
return
|
|
||||||
status_id = status['id']
|
|
||||||
if status['favourited']:
|
|
||||||
self.footer.draw_message("Undoing favourite status...", Color.YELLOW)
|
|
||||||
api.unfavourite(app, user, status_id)
|
|
||||||
self.footer.draw_message("✓ Status unfavourited", Color.GREEN)
|
|
||||||
else:
|
|
||||||
self.footer.draw_message("Favourite status...", Color.YELLOW)
|
|
||||||
api.favourite(app, user, status_id)
|
|
||||||
self.footer.draw_message("✓ Status favourited", Color.GREEN)
|
|
||||||
status['favourited'] = not status['favourited']
|
|
||||||
|
|
||||||
self.right.draw(status)
|
|
||||||
|
|
||||||
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 on_resize(self):
|
|
||||||
self.setup_windows()
|
|
||||||
self.full_redraw()
|
|
||||||
|
|
||||||
def full_redraw(self):
|
|
||||||
"""Perform a full redraw of the UI."""
|
|
||||||
self.left.draw_statuses(self.statuses, self.selected)
|
|
||||||
self.right.draw(self.get_selected_status())
|
|
||||||
|
|
||||||
self.header.draw(self.user)
|
|
||||||
self.draw_footer_status()
|
|
||||||
|
|
||||||
|
|
||||||
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))
|
|
@ -1,38 +0,0 @@
|
|||||||
from toot.utils import format_content
|
|
||||||
|
|
||||||
|
|
||||||
def parse_status(status):
|
|
||||||
_status = status.get('reblog') or status
|
|
||||||
account = parse_account(_status['account'])
|
|
||||||
content = list(format_content(_status['content']))
|
|
||||||
spoiler_text = list(format_content(_status['spoiler_text'])) if _status['spoiler_text'] else []
|
|
||||||
|
|
||||||
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,
|
|
||||||
'content': content,
|
|
||||||
'favourited': status.get('favourited'),
|
|
||||||
'favourites_count': _status['favourites_count'],
|
|
||||||
'id': status['id'],
|
|
||||||
'in_reply_to_id': _status.get('in_reply_to_id'),
|
|
||||||
'media_attachments': _status['media_attachments'],
|
|
||||||
'url': _status['url'],
|
|
||||||
'reblogged': status.get('reblogged'),
|
|
||||||
'reblogs_count': _status['reblogs_count'],
|
|
||||||
'replies_count': _status.get('replies_count', 0),
|
|
||||||
'spoiler_text': spoiler_text,
|
|
||||||
'sensitive': _status['sensitive'],
|
|
||||||
'show_sensitive': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_account(account):
|
|
||||||
return {
|
|
||||||
'id': account['id'],
|
|
||||||
'acct': account['acct'],
|
|
||||||
'display_name': account['display_name'],
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from toot.wcstring import fit_text, wc_wrap
|
|
||||||
|
|
||||||
|
|
||||||
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(lines, text_width, default_color):
|
|
||||||
def parse_line(line):
|
|
||||||
if isinstance(line, tuple) and len(line) == 2:
|
|
||||||
return line[0], line[1]
|
|
||||||
elif isinstance(line, str):
|
|
||||||
return line, default_color
|
|
||||||
elif line is None:
|
|
||||||
return "", default_color
|
|
||||||
|
|
||||||
raise ValueError("Wrong yield in generator")
|
|
||||||
|
|
||||||
def wrap_lines(lines):
|
|
||||||
for line in lines:
|
|
||||||
line, color = parse_line(line)
|
|
||||||
if line:
|
|
||||||
for wrapped in wc_wrap(line, text_width):
|
|
||||||
yield wrapped, color
|
|
||||||
else:
|
|
||||||
yield "", color
|
|
||||||
|
|
||||||
return enumerate(wrap_lines(lines))
|
|
||||||
|
|
||||||
|
|
||||||
HASHTAG_PATTERN = re.compile(r'(?<!\w)(#\w+)\b')
|
|
||||||
|
|
||||||
|
|
||||||
def highlight_hashtags(window, y, padding, line):
|
|
||||||
from toot.ui.app import Color
|
|
||||||
|
|
||||||
for match in re.finditer(HASHTAG_PATTERN, line):
|
|
||||||
start, end = match.span()
|
|
||||||
window.chgat(y, start + padding, end - start, Color.HASHTAG)
|
|
||||||
|
|
||||||
|
|
||||||
def size_as_drawn(lines, screen_width):
|
|
||||||
"""Get the bottom-right corner of some text as would be drawn by draw_lines"""
|
|
||||||
y = 0
|
|
||||||
x = 0
|
|
||||||
for line in lines:
|
|
||||||
wrapped = list(wc_wrap(line, screen_width))
|
|
||||||
if len(wrapped) > 0:
|
|
||||||
for wrapped_line in wrapped:
|
|
||||||
x = len(wrapped_line)
|
|
||||||
y += 1
|
|
||||||
else:
|
|
||||||
x = 0
|
|
||||||
y += 1
|
|
||||||
return y - 1, x - 1 if x != 0 else 0
|
|
||||||
|
|
||||||
|
|
||||||
def draw_lines(window, lines, start_y, padding, default_color):
|
|
||||||
height, width = window.getmaxyx()
|
|
||||||
text_width = width - 2 * padding
|
|
||||||
|
|
||||||
for dy, (line, color) in enumerate_lines(lines, text_width, default_color):
|
|
||||||
y = start_y + dy
|
|
||||||
if y < height - 1:
|
|
||||||
window.addstr(y, padding, fit_text(line, text_width), color)
|
|
||||||
highlight_hashtags(window, y, padding, line)
|
|
||||||
|
|
||||||
return y + 1
|
|
Loading…
Reference in New Issue
Block a user