chiark / gitweb /
Detect updates and append them to the file.
authorSimon Tatham <anakin@pobox.com>
Tue, 5 Dec 2023 13:05:36 +0000 (13:05 +0000)
committerSimon Tatham <anakin@pobox.com>
Tue, 5 Dec 2023 19:06:07 +0000 (19:06 +0000)
client.py
cursesclient.py
text.py
util.py

index 830572fcaa505b03a861b30090e3e139226772bc..d46d60be2808ed1306385214d8219cf769e177ee 100644 (file)
--- a/client.py
+++ b/client.py
@@ -5,6 +5,7 @@ import os
 import re
 import requests
 import string
+import sys
 import time
 
 import text
@@ -44,7 +45,7 @@ class Client:
         self.logfh = open(logfile, "w")
         pr = lambda *args, **kws: print(*args, file=self.logfh, **kws)
 
-        def log_response(rsp):
+        def log_response(rsp, content):
             pr(f"Request: {rsp.request.method} {rsp.request.url}")
             pr("  Request headers:")
             for k, v in rsp.request.headers.items():
@@ -53,22 +54,25 @@ class Client:
             pr("  Response headers:")
             for k, v in rsp.headers.items():
                 pr(f"    {k}: {v}")
-            if 'application/json' not in rsp.headers.get('content-type'):
-                pr(f"  Response: {rsp.content!r}")
-            else:
-                pr("  Response JSON:")
-                j = rsp.json()
-                for line in json.dumps(j, indent=4).splitlines():
-                    pr("    " + line)
+            if content:
+                if 'application/json' not in rsp.headers.get('content-type'):
+                    pr(f"  Response: {rsp.content!r}")
+                else:
+                    pr("  Response JSON:")
+                    j = rsp.json()
+                    for line in json.dumps(j, indent=4).splitlines():
+                        pr("    " + line)
+            self.logfh.flush()
 
         self.log_response = log_response
 
-    def method_start(self, method, path, base, params, links={}):
+    def method_start(self, method, path, base, params, stream, links={}):
         headers = {}
         if self.bearer_token is not None:
             headers['Authorization'] = 'Bearer ' + self.bearer_token
-        rsp = method(self.urls[base] + path, params=params, headers=headers)
-        self.log_response(rsp)
+        rsp = method(self.urls[base] + path, params=params, headers=headers,
+                     stream=stream)
+        self.log_response(rsp, content=not stream)
         if rsp.status_code != 200:
             raise HTTPError(rsp)
         linkhdr = rsp.headers.get('Link', '')
@@ -81,7 +85,8 @@ class Client:
         return rsp
 
     def method(self, method, path, base, params, links={}):
-        return self.method_start(method, path, base, params, links).json()
+        return self.method_start(method, path, base, params,
+                                 False, links).json()
 
     def get(self, path, base='api', **params):
         return self.method(requests.get, path, base, params)
@@ -110,13 +115,12 @@ class Client:
         return data, links
 
     def get_incremental_cont(self, link):
+        links = {}
         data = self.method(requests.get, link, None, {}, links)
         return data, links
 
     def get_streaming_lines(self, path, base='api', **params):
-        reqgetstream = lambda *args, **kws: requests.get(
-            *args, stream=True, **kws)
-        rsp = self.method_start(reqgetstream, path, base, params, {})
+        rsp = self.method_start(requests.get, path, base, params, True, {})
         if rsp.status_code != 200:
             raise HTTPError(rsp)
 
@@ -163,14 +167,16 @@ class IncrementalServerFeed(Feed):
         self.url = url
         self.params = params
         self.get = get
+        self.started = False
 
     def start(self):
-        data, self.links = self.client.get_incremental_start(
+        data, links = self.client.get_incremental_start(
             self.url, **self.params)
         self.data = list(reversed(data))
         self.origin = len(self.data)
-        self.prev_link = self.links['prev']
-        self.next_link = self.links['next']
+        self.prev_link = links['prev']
+        self.next_link = links['next']
+        self.started = True
 
     def min_index(self):
         return -self.origin
@@ -180,15 +186,23 @@ class IncrementalServerFeed(Feed):
         return self.data[n + self.origin]
 
     def extend_past(self):
-        data, links = self.client.get_incremental_cont(links, 'prev')
+        if not self.started:
+            return
+        data, links = self.client.get_incremental_cont(self.next_link)
+        if len(data) == 0:
+            return
         self.data[0:0] = list(reversed(data))
         self.origin += len(data)
-        self.prev_link = self.links['prev']
+        self.next_link = links['next']
 
     def extend_future(self):
-        data, links = self.client.get_incremental_cont(links, 'next')
+        if not self.started:
+            return
+        data, links = self.client.get_incremental_cont(self.prev_link)
+        if len(data) == 0:
+            return
         self.data.extend(reversed(data))
-        self.next_link = self.links['next']
+        self.prev_link = links['prev']
 
 class HomeTimelineFeed(IncrementalServerFeed):
     def __init__(self, client):
index 489e5e9cac4e84f4ea334072002719aae1c067c8..e046ceb82c8d73d34551da2939c0027d4307fdd2 100644 (file)
@@ -1,12 +1,18 @@
 import curses
 import itertools
+import select
 import sys
+import threading
 
 import client
 import text
 import util
 
 class CursesUI(client.Client):
+    def __init__(self):
+        super().__init__()
+        self.selfpipes = []
+
     def curses_setup(self):
         self.scr = curses.initscr()
         if hasattr(curses, 'start_color'):
@@ -85,12 +91,36 @@ class CursesUI(client.Client):
             self.print_at(y, 0, text.ColouredString(' ' * self.scr_w))
 
     def get_input(self):
-        # FIXME: add a select loop for self-pipes
-        return self.scr.getch()
+        rfds_in = [0]
+        for (sp, handler, _) in self.selfpipes:
+            rfds_in.append(sp.rfd)
+        rfds_out, _, _ = select.select(rfds_in, [], [])
+        rfds_out = set(rfds_out)
+        for (sp, handler, _) in self.selfpipes:
+            if sp.rfd in rfds_out and sp.check():
+                handler()
+        if 0 in rfds_out:
+            return self.scr.getch()
+        else:
+            return None
+
+    def add_selfpipe(self, url, handler):
+        sp = util.SelfPipe()
+        gen = self.get_streaming_lines(url)
+        def threadfn():
+            for line in gen:
+                # ignore heartbeat lines
+                if line.startswith("event"):
+                    sp.signal()
+        th = threading.Thread(target=threadfn, daemon=True)
+        th.start()
+        self.selfpipes.append((sp, handler, th))
 
     def run(self):
+        home_feed = self.home_timeline_feed()
+        self.add_selfpipe("streaming/user", home_feed.extend_future)
         self.home_timeline = StatusFile(
-            self, self.home_timeline_feed(),
+            self, home_feed,
             text.ColouredString("Home timeline   <H>",
                                 "HHHHHHHHHHHHHHHHHKH"))
 
@@ -149,10 +179,28 @@ class StatusFile:
             for thing in self.statuses[i].text():
                 yield thing, i # FIXME: maybe just yield the last?
 
-    def resize(self, width):
-        if self.width == width:
+    def fetch_new(self):
+        got_any = False
+
+        new_minpos = self.feed.min_index()
+        while self.minpos > new_minpos:
+            self.minpos -= 1
+            self.statuses[self.minpos] = client.Status(
+                self.feed[self.minpos], self.cc)
+            got_any = True
+
+        new_maxpos = self.feed.max_index()
+        while self.maxpos < new_maxpos:
+            self.statuses[self.maxpos] = client.Status(
+                self.feed[self.maxpos], self.cc)
+            self.maxpos += 1
+            got_any = True
+
+        return got_any
+
+    def regenerate_lines(self, width):
+        if self.width == width and not self.fetch_new():
             return
-        self.width = width
         self.lines = []
         pos = 0
         for thing, itemindex in self.iter_text_indexed():
@@ -161,10 +209,12 @@ class StatusFile:
                     self.lines.append(s)
             if itemindex == self.itempos:
                 pos = len(self.lines)
-        self.move_to(pos)
+        if self.width != width:
+            self.width = width
+            self.move_to(pos)
 
     def render(self):
-        self.resize(self.cc.scr_w)
+        self.regenerate_lines(self.cc.scr_w)
         topline = max(0, self.linepos - (self.cc.scr_h - 1))
         for y, line in enumerate(self.lines[topline:topline+self.cc.scr_h-1]):
             self.cc.print_at(y, 0, line)
diff --git a/text.py b/text.py
index c5e81833b4909b3138deeb3cc019e8edf603cbc4..5b514cbeff0939f0272a566c59a6c378b1e532e4 100644 (file)
--- a/text.py
+++ b/text.py
@@ -190,8 +190,7 @@ class ExtendableIndicator:
         rspace = space - lspace + 1
 
         yield ColouredString("")
-        yield (ColouredString(" " * lspace) + message +
-               ColouredString(" " * rspace))
+        yield ColouredString(" " * lspace) + message
         yield ColouredString("")
 
 class FileStatusLine:
diff --git a/util.py b/util.py
index fcd6578a34b8d55a3615af7e75fcbb6507a11580..611480796ac69b6c115fb0bba0ba7f0a65b00944 100644 (file)
--- a/util.py
+++ b/util.py
@@ -4,13 +4,25 @@ class SelfPipe:
     def __init__(self):
         self.rfd, self.wfd = os.pipe2(os.O_NONBLOCK | os.O_CLOEXEC)
 
+    def nonblocking_read(self, size=4096):
+        try:
+            return os.read(self.rfd, size)
+        except BlockingIOError:
+            return b''
+
+    def nonblocking_write(self, data):
+        try:
+            os.write(self.wfd, data)
+        except BlockingIOError:
+            pass
+
     def signal(self):
-        os.write(self.wfd, b'x')
+        self.nonblocking_write(b'x')
 
     def check(self):
-        if len(os.read(self, 4096)) == 0:
+        if len(self.nonblocking_read()) == 0:
             return False
-        while len(os.read(self, 4096)) != 0:
+        while len(self.nonblocking_read()) != 0:
             pass
         return True