mirror of
https://github.com/ihabunek/toot.git
synced 2024-12-04 14:46:33 -05:00
Initial implementation of a TUI using Urwid
This commit is contained in:
parent
616483d78a
commit
ea1ef6f207
3
setup.py
3
setup.py
@ -34,12 +34,13 @@ setup(
|
||||
'Programming Language :: Python :: 3.6',
|
||||
'Programming Language :: Python :: 3.7',
|
||||
],
|
||||
packages=['toot', 'toot.ui'],
|
||||
packages=['toot', 'toot.ui', 'toot.tui'],
|
||||
python_requires=">=3.4",
|
||||
install_requires=[
|
||||
"requests>=2.13,<3.0",
|
||||
"beautifulsoup4>=4.5.0,<5.0",
|
||||
"wcwidth>=0.1.7,<2.0",
|
||||
"urwid>=2.0.0,<3.0",
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
|
@ -324,3 +324,8 @@ def notifications(app, user, args):
|
||||
return
|
||||
|
||||
print_notifications(notifications)
|
||||
|
||||
|
||||
def tui(app, user, args):
|
||||
from .tui.app import TUI
|
||||
TUI.create(app, user).run()
|
||||
|
@ -275,6 +275,12 @@ READ_COMMANDS = [
|
||||
arguments=curses_args,
|
||||
require_auth=False,
|
||||
),
|
||||
Command(
|
||||
name="tui",
|
||||
description="Launches the TUI (terminal user interface).",
|
||||
arguments=curses_args,
|
||||
require_auth=False,
|
||||
),
|
||||
]
|
||||
|
||||
POST_COMMANDS = [
|
||||
|
5
toot/tui/NOTES.md
Normal file
5
toot/tui/NOTES.md
Normal file
@ -0,0 +1,5 @@
|
||||
maybe ???
|
||||
https://github.com/CanonicalLtd/subiquity/blob/master/subiquitycore/core.py#L280
|
||||
|
||||
educational:
|
||||
https://github.com/TomasTomecek/sen/blob/master/sen/tui/ui.py
|
10
toot/tui/__init__.py
Normal file
10
toot/tui/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
from urwid.command_map import command_map
|
||||
from urwid.command_map import CURSOR_UP, CURSOR_DOWN, CURSOR_LEFT, CURSOR_RIGHT
|
||||
|
||||
# Add movement using h/j/k/l to default command map
|
||||
command_map._command.update({
|
||||
'k': CURSOR_UP,
|
||||
'j': CURSOR_DOWN,
|
||||
'h': CURSOR_LEFT,
|
||||
'l': CURSOR_RIGHT,
|
||||
})
|
114
toot/tui/app.py
Normal file
114
toot/tui/app.py
Normal file
@ -0,0 +1,114 @@
|
||||
import logging
|
||||
import urwid
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from toot.api import home_timeline_generator
|
||||
|
||||
from .constants import PALETTE
|
||||
from .entities import Status
|
||||
from .timeline import Timeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Header(urwid.WidgetWrap):
|
||||
def __init__(self, app, user):
|
||||
self.app = app
|
||||
self.user = user
|
||||
|
||||
self.text = urwid.Text("")
|
||||
self.cols = urwid.Columns([
|
||||
("pack", urwid.Text(('header_bold', 'toot'))),
|
||||
("pack", urwid.Text(('header', f' | {user.username}@{app.instance}'))),
|
||||
("pack", self.text),
|
||||
])
|
||||
|
||||
widget = urwid.AttrMap(self.cols, 'header')
|
||||
widget = urwid.Padding(widget)
|
||||
self._wrapped_widget = widget
|
||||
|
||||
def clear_text(self, text):
|
||||
self.text.set_text("")
|
||||
|
||||
def set_text(self, text):
|
||||
self.text.set_text(" | " + text)
|
||||
|
||||
|
||||
class Footer(urwid.Pile):
|
||||
def __init__(self):
|
||||
self.status = urwid.Text("")
|
||||
self.message = urwid.Text("")
|
||||
|
||||
return super().__init__([
|
||||
urwid.AttrMap(self.status, "footer_status"),
|
||||
urwid.AttrMap(self.message, "footer_message"),
|
||||
])
|
||||
|
||||
def set_status(self, text):
|
||||
self.status.set_text(text)
|
||||
|
||||
def set_message(self, text):
|
||||
self.message.set_text(text)
|
||||
|
||||
def set_error(self, text):
|
||||
# TODO: change to red
|
||||
self.message.set_text(text)
|
||||
|
||||
|
||||
class TUI(urwid.Frame):
|
||||
"""Main TUI frame."""
|
||||
|
||||
@classmethod
|
||||
def create(cls, app, user):
|
||||
"""Factory method, sets up TUI and an event loop."""
|
||||
|
||||
tui = cls(app, user)
|
||||
loop = urwid.MainLoop(
|
||||
tui,
|
||||
palette=PALETTE,
|
||||
event_loop=urwid.AsyncioEventLoop(),
|
||||
unhandled_input=tui.unhandled_input,
|
||||
)
|
||||
tui.loop = loop
|
||||
|
||||
return tui
|
||||
|
||||
def __init__(self, app, user):
|
||||
self.app = app
|
||||
self.user = user
|
||||
|
||||
self.loop = None # set in `create`
|
||||
self.executor = ThreadPoolExecutor(max_workers=1)
|
||||
self.timeline_generator = home_timeline_generator(app, user, limit=40)
|
||||
|
||||
self.body = urwid.Filler(urwid.Text("Loading toots...", align="center"))
|
||||
self.header = Header(app, user)
|
||||
self.footer = Footer()
|
||||
self.footer.set_status("Loading...")
|
||||
|
||||
super().__init__(self.body, header=self.header, footer=self.footer)
|
||||
|
||||
def run(self):
|
||||
self.loop.set_alarm_in(0, self.schedule_loading_toots)
|
||||
self.loop.run()
|
||||
self.executor.shutdown(wait=False)
|
||||
|
||||
def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None):
|
||||
future = self.executor.submit(self.load_toots)
|
||||
if done_callback:
|
||||
future.add_done_callback(done_callback)
|
||||
|
||||
def schedule_loading_toots(self, *args):
|
||||
self.run_in_thread(self.load_toots, done_callback=self.toots_loaded)
|
||||
|
||||
def load_toots(self):
|
||||
data = next(self.timeline_generator)
|
||||
return [Status(s, self.app.instance) for s in data]
|
||||
|
||||
def toots_loaded(self, future):
|
||||
self.body = Timeline(self, future.result())
|
||||
|
||||
def unhandled_input(self, key):
|
||||
if key in ('q', 'Q'):
|
||||
raise urwid.ExitMainLoop()
|
22
toot/tui/constants.py
Normal file
22
toot/tui/constants.py
Normal file
@ -0,0 +1,22 @@
|
||||
# name, fg, bg, mono, fg_h, bg_h
|
||||
PALETTE = [
|
||||
# Header
|
||||
('header', 'white', 'dark blue'),
|
||||
('header_bold', 'white,bold', 'dark blue'),
|
||||
|
||||
# Footer
|
||||
('footer_status', 'white', 'dark blue'),
|
||||
('footer_message', 'dark green', ''),
|
||||
|
||||
# by color name
|
||||
('blue', 'light blue', ''),
|
||||
('blue_bold', 'light blue, bold', ''),
|
||||
('blue_selected', 'white,bold', 'dark blue'),
|
||||
('cyan', 'dark cyan', ''),
|
||||
('cyan_bold', 'dark cyan,bold', ''),
|
||||
('green', 'dark green', ''),
|
||||
('green_selected', 'white,bold', 'dark green'),
|
||||
('italic', 'white', ''),
|
||||
('yellow', 'yellow', ''),
|
||||
('yellow_selected', 'yellow', 'dark blue'),
|
||||
]
|
25
toot/tui/entities.py
Normal file
25
toot/tui/entities.py
Normal file
@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def parse_datetime(value):
|
||||
"""Returns an aware datetime in local timezone"""
|
||||
return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f%z").astimezone()
|
||||
|
||||
|
||||
class Status:
|
||||
"""
|
||||
A wrapper around the Status entity data fetched from Mastodon.
|
||||
|
||||
https://github.com/tootsuite/documentation/blob/master/Using-the-API/API.md#status
|
||||
"""
|
||||
def __init__(self, data, instance):
|
||||
self.data = data
|
||||
self.instance = instance
|
||||
|
||||
self.id = self.data["id"]
|
||||
self.account = self.get_account()
|
||||
self.created_at = parse_datetime(data["created_at"])
|
||||
|
||||
def get_account(self):
|
||||
acct = self.data['account']['acct']
|
||||
return acct if "@" in acct else "{}@{}".format(acct, self.instance)
|
90
toot/tui/timeline.py
Normal file
90
toot/tui/timeline.py
Normal file
@ -0,0 +1,90 @@
|
||||
import logging
|
||||
import urwid
|
||||
|
||||
from .widgets import SelectableText, SelectableColumns
|
||||
|
||||
logger = logging.getLogger("toot")
|
||||
|
||||
|
||||
class Timeline(urwid.Columns):
|
||||
"""
|
||||
Displays a list of statuses to the left, and status details on the right.
|
||||
|
||||
TODO: Switch to top/bottom for narrow views.
|
||||
"""
|
||||
|
||||
signals = ["status_focused"]
|
||||
|
||||
def __init__(self, tui, statuses):
|
||||
self.tui = tui
|
||||
self.statuses = statuses
|
||||
self.instance = tui.app.instance
|
||||
|
||||
self.status_list = self.build_status_list(statuses)
|
||||
self.status_details = self.build_status_details(statuses[0], self.instance)
|
||||
|
||||
# TODO:
|
||||
# self.status_cache = {}
|
||||
|
||||
super().__init__([
|
||||
("weight", 50, self.status_list),
|
||||
("weight", 50, self.status_details),
|
||||
], dividechars=1)
|
||||
|
||||
def build_status_list(self, statuses):
|
||||
items = [self.list_item(status) for status in statuses]
|
||||
walker = urwid.SimpleFocusListWalker(items)
|
||||
urwid.connect_signal(walker, "modified", self.status_focused)
|
||||
return urwid.ListBox(walker)
|
||||
|
||||
def build_status_details(self, status, instance):
|
||||
details = StatusDetails(status, instance)
|
||||
return urwid.Filler(details, valign="top")
|
||||
|
||||
def get_focused_status(self):
|
||||
return self.statuses[self.status_list.body.focus]
|
||||
|
||||
def status_activated(self, *args):
|
||||
"""Called when a status is clicked, or Enter is pressed."""
|
||||
# logger.info("status_activated " + str(args))
|
||||
|
||||
def status_focused(self):
|
||||
"""Called when the list focus switches to a new status"""
|
||||
status = self.get_focused_status()
|
||||
details = StatusDetails(status, self.instance)
|
||||
self.status_details.set_body(details)
|
||||
self._emit("status_focused", [status])
|
||||
|
||||
def list_item(self, status):
|
||||
item = StatusListItem(status, self.instance)
|
||||
urwid.connect_signal(item, "click", self.status_activated)
|
||||
return urwid.AttrMap(item, None, focus_map={
|
||||
"blue": "green_selected",
|
||||
"green": "green_selected",
|
||||
"yellow": "green_selected",
|
||||
None: "green_selected",
|
||||
})
|
||||
|
||||
|
||||
class StatusDetails(urwid.Pile):
|
||||
def __init__(self, status, instance):
|
||||
return super().__init__([
|
||||
urwid.Text(status.id)
|
||||
])
|
||||
|
||||
|
||||
|
||||
class StatusListItem(SelectableColumns):
|
||||
def __init__(self, status, instance):
|
||||
created_at = status.created_at.strftime("%Y-%m-%d %H:%M")
|
||||
favourited = ("yellow", "★") if status.data["favourited"] else " "
|
||||
reblogged = ("yellow", "⤶") if status.data["reblogged"] else " "
|
||||
|
||||
return super().__init__([
|
||||
("pack", SelectableText(("blue", created_at), wrap="clip")),
|
||||
("pack", urwid.Text(" ")),
|
||||
urwid.Text(("green", status.account), wrap="clip"),
|
||||
("pack", urwid.Text(" ")),
|
||||
("pack", urwid.Text(favourited)),
|
||||
("pack", urwid.Text(reblogged)),
|
||||
])
|
31
toot/tui/widgets.py
Normal file
31
toot/tui/widgets.py
Normal file
@ -0,0 +1,31 @@
|
||||
import urwid
|
||||
|
||||
|
||||
class Clickable:
|
||||
"""
|
||||
Add a `click` signal which is sent when the item is activated or clicked.
|
||||
|
||||
TODO: make it work on widgets which have other signals.
|
||||
"""
|
||||
signals = ["click"]
|
||||
|
||||
def keypress(self, size, key):
|
||||
if self._command_map[key] == urwid.ACTIVATE:
|
||||
self._emit('click')
|
||||
return
|
||||
|
||||
return key
|
||||
|
||||
def mouse_event(self, size, event, button, x, y, focus):
|
||||
if button == 1:
|
||||
self._emit('click')
|
||||
|
||||
return super().mouse_event(size, event, button, x, y, focus)
|
||||
|
||||
|
||||
class SelectableText(Clickable, urwid.Text):
|
||||
_selectable = True
|
||||
|
||||
|
||||
class SelectableColumns(Clickable, urwid.Columns):
|
||||
_selectable = True
|
Loading…
Reference in New Issue
Block a user