kopia lustrzana https://github.com/ihabunek/toot
				
				
				
			Overhaul async actions, implement boost and reblog
							rodzic
							
								
									2349173a45
								
							
						
					
					
						commit
						372976b1b2
					
				| 
						 | 
				
			
			@ -7,5 +7,10 @@ https://github.com/TomasTomecek/sen/blob/master/sen/tui/ui.py
 | 
			
		|||
check out:
 | 
			
		||||
https://github.com/rndusr/stig/tree/master/stig/tui
 | 
			
		||||
 | 
			
		||||
TODO/Ideas:
 | 
			
		||||
* pack left column in timeline view
 | 
			
		||||
* when an error happens, show it in the status bar and have "press E to view exception" to show it in an overlay.
 | 
			
		||||
    * maybe even have error reporting? e.g. button to open an issue on github?
 | 
			
		||||
 | 
			
		||||
Questions:
 | 
			
		||||
* is it possible to make a span a urwid.Text selectable? e.g. for urls and hashtags
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										133
									
								
								toot/tui/app.py
								
								
								
								
							
							
						
						
									
										133
									
								
								toot/tui/app.py
								
								
								
								
							| 
						 | 
				
			
			@ -55,6 +55,9 @@ class Footer(urwid.Pile):
 | 
			
		|||
    def set_message(self, text):
 | 
			
		||||
        self.message.set_text(text)
 | 
			
		||||
 | 
			
		||||
    def set_error_message(self, text):
 | 
			
		||||
        self.message.set_text(("footer_message_error", text))
 | 
			
		||||
 | 
			
		||||
    def clear_message(self):
 | 
			
		||||
        self.message.set_text("")
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -97,51 +100,73 @@ class TUI(urwid.Frame):
 | 
			
		|||
        super().__init__(self.body, header=self.header, footer=self.footer)
 | 
			
		||||
 | 
			
		||||
    def run(self):
 | 
			
		||||
        self.loop.set_alarm_in(0, self.schedule_load_statuses)
 | 
			
		||||
        self.loop.set_alarm_in(0, lambda *args: self.async_load_statuses(is_initial=True))
 | 
			
		||||
        self.loop.run()
 | 
			
		||||
        self.executor.shutdown(wait=False)
 | 
			
		||||
 | 
			
		||||
    def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None):
 | 
			
		||||
        future = self.executor.submit(fn)
 | 
			
		||||
        if done_callback:
 | 
			
		||||
            future.add_done_callback(done_callback)
 | 
			
		||||
    def run_in_thread(self, fn, args=[], kwargs={}, done_callback=None, error_callback=None):
 | 
			
		||||
        """Runs `fn(*args, **kwargs)` asynchronously in a separate thread.
 | 
			
		||||
 | 
			
		||||
    def schedule_load_statuses(self, *args):
 | 
			
		||||
        self.run_in_thread(self.load_statuses, done_callback=self.statuses_loaded_initial)
 | 
			
		||||
        On completion calls `done_callback` if `fn` exited cleanly, or
 | 
			
		||||
        `error_callback` if an exception was caught. Callback methods are
 | 
			
		||||
        invoked in the main thread, not the thread in which `fn` is executed.
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    def load_statuses(self):
 | 
			
		||||
        self.footer.set_message("Loading statuses...")
 | 
			
		||||
        try:
 | 
			
		||||
            data = next(self.timeline_generator)
 | 
			
		||||
        except StopIteration:
 | 
			
		||||
            return []
 | 
			
		||||
        finally:
 | 
			
		||||
            self.footer.clear_message()
 | 
			
		||||
        def _default_error_callback(ex):
 | 
			
		||||
            self.exception = ex
 | 
			
		||||
            self.footer.set_error_message("An exeption occured, press E to view")
 | 
			
		||||
 | 
			
		||||
        # # FIXME: REMOVE DEBUGGING
 | 
			
		||||
        # with open("tmp/statuses2.json", "w") as f:
 | 
			
		||||
        #     import json
 | 
			
		||||
        #     json.dump(data, f, indent=4)
 | 
			
		||||
        _error_callback = error_callback or _default_error_callback
 | 
			
		||||
 | 
			
		||||
        return [Status(s, self.app.instance) for s in data]
 | 
			
		||||
        def _done(future):
 | 
			
		||||
            try:
 | 
			
		||||
                result = future.result()
 | 
			
		||||
                if done_callback:
 | 
			
		||||
                    # Use alarm to invoke callback in main thread
 | 
			
		||||
                    self.loop.set_alarm_in(0, lambda *args: done_callback(result))
 | 
			
		||||
            except Exception as ex:
 | 
			
		||||
                exception = ex
 | 
			
		||||
                logger.exception(exception)
 | 
			
		||||
                self.loop.set_alarm_in(0, lambda *args: _error_callback(exception))
 | 
			
		||||
 | 
			
		||||
    def schedule_load_next(self):
 | 
			
		||||
        self.run_in_thread(self.load_statuses, done_callback=self.statuses_loaded_next)
 | 
			
		||||
        future = self.executor.submit(fn, *args, **kwargs)
 | 
			
		||||
        future.add_done_callback(_done)
 | 
			
		||||
 | 
			
		||||
    def statuses_loaded_initial(self, future):
 | 
			
		||||
        # TODO: handle errors in future
 | 
			
		||||
        self.timeline = Timeline(self, future.result())
 | 
			
		||||
 | 
			
		||||
        urwid.connect_signal(self.timeline, "status_focused",
 | 
			
		||||
    def build_timeline(self, statuses):
 | 
			
		||||
        timeline = Timeline(self, statuses)
 | 
			
		||||
        urwid.connect_signal(timeline, "status_focused",
 | 
			
		||||
            lambda _, args: self.status_focused(*args))
 | 
			
		||||
        urwid.connect_signal(self.timeline, "next",
 | 
			
		||||
            lambda *args: self.schedule_load_next())
 | 
			
		||||
        self.timeline.status_focused()  # Draw first status
 | 
			
		||||
        self.body = self.timeline
 | 
			
		||||
        urwid.connect_signal(timeline, "next",
 | 
			
		||||
            lambda *args: self.async_load_statuses(is_initial=False))
 | 
			
		||||
        return timeline
 | 
			
		||||
 | 
			
		||||
    def statuses_loaded_next(self, future):
 | 
			
		||||
        # TODO: handle errors in future
 | 
			
		||||
        self.timeline.add_statuses(future.result())
 | 
			
		||||
    def async_load_statuses(self, is_initial):
 | 
			
		||||
        """Asynchronously load a list of statuses."""
 | 
			
		||||
 | 
			
		||||
        def _load_statuses():
 | 
			
		||||
            self.footer.set_message("Loading statuses...")
 | 
			
		||||
            try:
 | 
			
		||||
                data = next(self.timeline_generator)
 | 
			
		||||
            except StopIteration:
 | 
			
		||||
                return []
 | 
			
		||||
            finally:
 | 
			
		||||
                self.footer.clear_message()
 | 
			
		||||
 | 
			
		||||
            return [Status(s, self.app.instance) for s in data]
 | 
			
		||||
 | 
			
		||||
        def _done_initial(statuses):
 | 
			
		||||
            """Process initial batch of statuses, construct a Timeline."""
 | 
			
		||||
            self.timeline = self.build_timeline(statuses)
 | 
			
		||||
            self.timeline.status_focused()  # Draw first status
 | 
			
		||||
            self.body = self.timeline
 | 
			
		||||
 | 
			
		||||
        def _done_next(statuses):
 | 
			
		||||
            """Process sequential batch of statuses, adds statuses to the
 | 
			
		||||
            existing timeline."""
 | 
			
		||||
            self.timeline.add_statuses(statuses)
 | 
			
		||||
 | 
			
		||||
        self.run_in_thread(_load_statuses,
 | 
			
		||||
            done_callback=_done_initial if is_initial else _done_next)
 | 
			
		||||
 | 
			
		||||
    def status_focused(self, status, index, count):
 | 
			
		||||
        self.footer.set_status([
 | 
			
		||||
| 
						 | 
				
			
			@ -161,6 +186,46 @@ class TUI(urwid.Frame):
 | 
			
		|||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def async_toggle_favourite(self, status):
 | 
			
		||||
        def _favourite():
 | 
			
		||||
            logger.info("Favouriting {}".format(status))
 | 
			
		||||
            api.favourite(self.app, self.user, status.id)
 | 
			
		||||
 | 
			
		||||
        def _unfavourite():
 | 
			
		||||
            logger.info("Unfavouriting {}".format(status))
 | 
			
		||||
            api.unfavourite(self.app, self.user, status.id)
 | 
			
		||||
 | 
			
		||||
        def _done(loop):
 | 
			
		||||
            # Create a new Status with flipped favourited flag
 | 
			
		||||
            new_data = status.data
 | 
			
		||||
            new_data["favourited"] = not status.favourited
 | 
			
		||||
            self.timeline.update_status(Status(new_data, status.instance))
 | 
			
		||||
 | 
			
		||||
        self.run_in_thread(
 | 
			
		||||
            _unfavourite if status.favourited else _favourite,
 | 
			
		||||
            done_callback=_done
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def async_toggle_reblog(self, status):
 | 
			
		||||
        def _reblog():
 | 
			
		||||
            logger.info("Reblogging {}".format(status))
 | 
			
		||||
            api.reblog(self.app, self.user, status.id)
 | 
			
		||||
 | 
			
		||||
        def _unreblog():
 | 
			
		||||
            logger.info("Unreblogging {}".format(status))
 | 
			
		||||
            api.unreblog(self.app, self.user, status.id)
 | 
			
		||||
 | 
			
		||||
        def _done(loop):
 | 
			
		||||
            # Create a new Status with flipped reblogged flag
 | 
			
		||||
            new_data = status.data
 | 
			
		||||
            new_data["reblogged"] = not status.reblogged
 | 
			
		||||
            self.timeline.update_status(Status(new_data, status.instance))
 | 
			
		||||
 | 
			
		||||
        self.run_in_thread(
 | 
			
		||||
            _unreblog if status.reblogged else _reblog,
 | 
			
		||||
            done_callback=_done
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    # --- Overlay handling -----------------------------------------------------
 | 
			
		||||
 | 
			
		||||
    def open_overlay(self, widget, options={}, title=""):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
PALETTE = [
 | 
			
		||||
    # Components
 | 
			
		||||
    ('footer_message', 'dark green', ''),
 | 
			
		||||
    ('footer_message_error', 'white', 'dark red'),
 | 
			
		||||
    ('footer_status', 'white', 'dark blue'),
 | 
			
		||||
    ('footer_status_bold', 'white, bold', 'dark blue'),
 | 
			
		||||
    ('header', 'white', 'dark blue'),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,15 +28,19 @@ class Status:
 | 
			
		|||
        self.data = data
 | 
			
		||||
        self.instance = instance
 | 
			
		||||
 | 
			
		||||
        # TODO: make Status immutable?
 | 
			
		||||
 | 
			
		||||
        self.id = self.data["id"]
 | 
			
		||||
        self.display_name = self.data["account"]["display_name"]
 | 
			
		||||
        self.account = self.get_account()
 | 
			
		||||
        self.created_at = parse_datetime(data["created_at"])
 | 
			
		||||
        self.author = get_author(data, instance)
 | 
			
		||||
 | 
			
		||||
        self.favourited = data.get("favourited", False)
 | 
			
		||||
        self.reblogged = data.get("reblogged", False)
 | 
			
		||||
 | 
			
		||||
    def get_account(self):
 | 
			
		||||
        acct = self.data['account']['acct']
 | 
			
		||||
        return acct if "@" in acct else "{}@{}".format(acct, self.instance)
 | 
			
		||||
 | 
			
		||||
    def __repr__(self):
 | 
			
		||||
        return "<Status id={}>".format(self.id)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,6 +2,7 @@ import logging
 | 
			
		|||
import urwid
 | 
			
		||||
import webbrowser
 | 
			
		||||
 | 
			
		||||
from toot import api
 | 
			
		||||
from toot.utils import format_content
 | 
			
		||||
 | 
			
		||||
from .utils import highlight_hashtags
 | 
			
		||||
| 
						 | 
				
			
			@ -30,9 +31,14 @@ class Timeline(urwid.Columns):
 | 
			
		|||
        self.status_list = self.build_status_list(statuses)
 | 
			
		||||
        self.status_details = StatusDetails(statuses[0])
 | 
			
		||||
 | 
			
		||||
        # Maps status ID to its index in the list
 | 
			
		||||
        self.status_index_map = {
 | 
			
		||||
            status.id: n for n, status in enumerate(statuses)
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        super().__init__([
 | 
			
		||||
            ("weight", 50, self.status_list),
 | 
			
		||||
            ("weight", 50, self.status_details),
 | 
			
		||||
            ("weight", 40, self.status_list),
 | 
			
		||||
            ("weight", 60, self.status_details),
 | 
			
		||||
        ], dividechars=1)
 | 
			
		||||
 | 
			
		||||
    def build_status_list(self, statuses):
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +85,16 @@ class Timeline(urwid.Columns):
 | 
			
		|||
            if index >= count:
 | 
			
		||||
                self._emit("next")
 | 
			
		||||
 | 
			
		||||
        if key in ("b", "B"):
 | 
			
		||||
            status = self.get_focused_status()
 | 
			
		||||
            self.tui.async_toggle_reblog(status)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if key in ("f", "F"):
 | 
			
		||||
            status = self.get_focused_status()
 | 
			
		||||
            self.tui.async_toggle_favourite(status)
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if key in ("v", "V"):
 | 
			
		||||
            status = self.get_focused_status()
 | 
			
		||||
            webbrowser.open(status.data["url"])
 | 
			
		||||
| 
						 | 
				
			
			@ -91,11 +107,30 @@ class Timeline(urwid.Columns):
 | 
			
		|||
 | 
			
		||||
        return super().keypress(size, key)
 | 
			
		||||
 | 
			
		||||
    def add_statuses(self, statuses):
 | 
			
		||||
        self.statuses += statuses
 | 
			
		||||
        new_items = [self.build_list_item(status) for status in statuses]
 | 
			
		||||
        self.status_list.body.extend(new_items)
 | 
			
		||||
    def add_status(self, status):
 | 
			
		||||
        self.statuses.append(status)
 | 
			
		||||
        self.status_index_map[status.id] = len(self.statuses) - 1
 | 
			
		||||
        self.status_list.body.append(self.build_list_item(status))
 | 
			
		||||
 | 
			
		||||
    def add_statuses(self, statuses):
 | 
			
		||||
        for status in statuses:
 | 
			
		||||
            self.add_status(status)
 | 
			
		||||
 | 
			
		||||
    def update_status(self, status):
 | 
			
		||||
        """Overwrite status in list with the new instance and redraw."""
 | 
			
		||||
        index = self.status_index_map[status.id]
 | 
			
		||||
        assert self.statuses[index].id == status.id
 | 
			
		||||
 | 
			
		||||
        # Update internal status list
 | 
			
		||||
        self.statuses[index] = status
 | 
			
		||||
 | 
			
		||||
        # Redraw list item
 | 
			
		||||
        self.status_list.body[index] = self.build_list_item(status)
 | 
			
		||||
 | 
			
		||||
        # Redraw status details if status is focused
 | 
			
		||||
        if index == self.status_list.body.focus:
 | 
			
		||||
            self.status_details = StatusDetails(status)
 | 
			
		||||
            self.contents[1] = self.status_details, ("weight", 50, False)
 | 
			
		||||
 | 
			
		||||
class StatusDetails(urwid.Pile):
 | 
			
		||||
    def __init__(self, status):
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Ładowanie…
	
		Reference in New Issue