From fb198adfeac8587c62418ea0c492718e415222c5 Mon Sep 17 00:00:00 2001 From: Simon Tatham Date: Thu, 7 Dec 2023 18:52:56 +0000 Subject: [PATCH] All the machinery is ready to try to post! --- client.py | 6 ++ cursesclient.py | 207 ++++++++++++++++++++++++++++++------------------ 2 files changed, 138 insertions(+), 75 deletions(-) diff --git a/client.py b/client.py index ebdf714..8046b5f 100644 --- a/client.py +++ b/client.py @@ -291,6 +291,8 @@ class Status: hp.feed(reply_status['content']) except HTTPError as ex: hp.feed(f'[unavailable: {ex.response.status_code}]') + except KeyError: # returned 200 with an empty JSON object + hp.feed(f'[unavailable]') hp.done() yield text.InReplyToLine(hp.paras) yield text.BlankLine() @@ -304,6 +306,8 @@ class Status: yield self.client.fq(self.account['acct']) for mention in self.mentions: yield self.client.fq(mention['acct']) + def get_reply_id(self): + return self.post_id class Notification: def __init__(self, data, client): @@ -329,3 +333,5 @@ class Notification: def get_reply_recipients(self): yield self.client.fq(self.account['acct']) + def get_reply_id(self): + return None diff --git a/cursesclient.py b/cursesclient.py index 1e9c1f6..6cdbd8f 100644 --- a/cursesclient.py +++ b/cursesclient.py @@ -158,8 +158,8 @@ class CursesUI(client.Client): self.composer = Composer(self) return self.composer - def new_composer(self, text): - self.composer = Composer(self, text) + def new_composer(self, text, reply_header, reply_id): + self.composer = Composer(self, text, reply_header, reply_id) return self.composer def run(self): @@ -545,13 +545,32 @@ class ObjectFile(File): print("next ->", self.send_target, file=sys.stderr) def send_complete(self): self.mode = 'normal' + recipients = collections.OrderedDict() for r in self.statuses[self.send_target].get_reply_recipients(): if r == self.cc.fq_username or r in recipients: continue recipients[r] = 1 - self.push_to(self.cc.new_composer("".join( - f"@{r} " for r in recipients))) + initial_content = "".join(f"@{r} " for r in recipients) + + reply_id = self.statuses[self.send_target].get_reply_id() + if reply_id is not None: + hp = text.HTMLParser() + try: + reply_status = self.cc.get_status_by_id(reply_id) + hp.feed(reply_status['content']) + except client.HTTPError as ex: + hp.feed(f'[unavailable: {ex.response.status_code}]') + except KeyError: # returned 200 with an empty JSON object + hp.feed(f'[unavailable]') + hp.done() + reply_header = text.InReplyToLine(hp.paras) + else: + reply_header = None + + self.push_to(self.cc.new_composer( + initial_content, reply_header, reply_id)) + def move_to(self, pos): old_linepos = self.linepos @@ -694,12 +713,20 @@ class Composer: y += 1 pos = next_nl + 1 - def __init__(self, cc, initial_text=""): + def __init__(self, cc, initial_text="", reply_header=None, reply_id=None): self.cc = cc - self.header = text.FileHeader("Compose a post") + self.reply_header = reply_header + self.reply_id = reply_id + if self.reply_header is None: + assert self.reply_id is None + self.header = text.FileHeader("Compose a post") + else: + assert self.reply_id is not None + self.header = text.FileHeader("Compose a reply") self.text = initial_text self.point = len(self.text) self.goal_column = None + self.mode = 'normal' def render(self): y = 0 @@ -708,6 +735,11 @@ class Composer: self.cc.print_at(y, 0, line) y += 1 + if self.reply_header is not None: + for line in self.reply_header.render(self.cc.scr_w): + self.cc.print_at(y, 0, line) + y += 1 + # FIXME: here the real Mono editor has some keypress help of the form # [F1]:Options (or [^O]) [F3]:Mark Block [F6]:Goto [F8]:Read File # [F2]:Finish [F7]:Find [F9]:Write File @@ -740,80 +772,105 @@ class Composer: if self.goal_column is None or not is_updown: self.goal_column = self.cx - # TODO: - # - # ^W,^T are Mono's keys for moving back/forward a word. - # - # Not sure what to do about ^K/^Y for one-line cut-paste, given - # that the autowrap makes the semantics a bit weird compared to - # the original setup. - # - # Probably don't want to exactly replicate the block - # operations either. Perhaps I should invent my own more sensible - # approach to cut, copy and paste. - - if ch in {ctrl('b'), curses.KEY_LEFT}: - self.move_to(self.point - 1) - elif ch in {ctrl('f'), curses.KEY_RIGHT}: - self.move_to(self.point + 1) - elif ch in {ctrl('n'), curses.KEY_DOWN}: - try: - new_point = util.last( - (i for i, yx in enumerate(self.dtext.yx[self.point:], - self.point) - if yx <= (self.cy + 1, self.goal_column))) - except ValueError: - new_point = len(self.text) - - self.move_to(new_point) - if self.dtext.yx[self.point][0] != self.cy + 1: - # Failed to go down; probably went to the end; reset - # the goal column. - self.goal_column = None - elif ch in {ctrl('p'), curses.KEY_UP}: - try: + if self.mode == 'normal': + # TODO: + # + # ^W,^T are Mono's keys for moving back/forward a word. + # + # Not sure what to do about ^K/^Y for one-line cut-paste, given + # that the autowrap makes the semantics a bit weird compared to + # the original setup. + # + # Probably don't want to exactly replicate the block + # operations either. Perhaps I should invent my own more sensible + # approach to cut, copy and paste. + + if ch in {ctrl('b'), curses.KEY_LEFT}: + self.move_to(self.point - 1) + elif ch in {ctrl('f'), curses.KEY_RIGHT}: + self.move_to(self.point + 1) + elif ch in {ctrl('n'), curses.KEY_DOWN}: + try: + new_point = util.last( + (i for i, yx in enumerate(self.dtext.yx[self.point:], + self.point) + if yx <= (self.cy + 1, self.goal_column))) + except ValueError: + new_point = len(self.text) + + self.move_to(new_point) + if self.dtext.yx[self.point][0] != self.cy + 1: + # Failed to go down; probably went to the end; reset + # the goal column. + self.goal_column = None + elif ch in {ctrl('p'), curses.KEY_UP}: + try: + new_point = util.last( + (i for i, yx in enumerate(self.dtext.yx[:self.point+1]) + if yx <= (self.cy - 1, self.goal_column))) + except ValueError: + new_point = 0 + + self.move_to(new_point) + if self.dtext.yx[self.point][0] != self.cy - 1: + # Failed to go down; probably went to the start; reset + # the goal column. + self.goal_column = None + elif ch in {ctrl('a'), curses.KEY_HOME}: + new_point = next( + i for i, yx in enumerate(self.dtext.yx[:self.point+1]) + if yx[0] == self.cy) + self.move_to(new_point) + elif ch in {ctrl('e'), curses.KEY_END}: new_point = util.last( - (i for i, yx in enumerate(self.dtext.yx[:self.point+1]) - if yx <= (self.cy - 1, self.goal_column))) - except ValueError: - new_point = 0 - - self.move_to(new_point) - if self.dtext.yx[self.point][0] != self.cy - 1: - # Failed to go down; probably went to the start; reset - # the goal column. - self.goal_column = None - elif ch in {ctrl('a'), curses.KEY_HOME}: - new_point = next( - i for i, yx in enumerate(self.dtext.yx[:self.point+1]) - if yx[0] == self.cy) - self.move_to(new_point) - elif ch in {ctrl('e'), curses.KEY_END}: - new_point = util.last( - i for i, yx in enumerate(self.dtext.yx[self.point:], - self.point) - if yx[0] == self.cy) - self.move_to(new_point) - elif ch in {ctrl('h'), '\x7F', curses.KEY_BACKSPACE}: - if self.point > 0: - self.text = self.text[:self.point - 1] + self.text[self.point:] - self.point -= 1 - elif ch in {ctrl('d'), curses.KEY_DC}: - if self.point < len(self.text): - self.text = self.text[:self.point] + self.text[self.point + 1:] - elif ch in {'\r', '\n'}: - self.text = (self.text[:self.point] + '\n' + - self.text[self.point:]) - self.point += 1 - elif isinstance(ch, str) and (' ' <= ch < '\x7F' or '\xA0' <= ch): - # TODO: overwrite mode - self.text = (self.text[:self.point] + ch + - self.text[self.point:]) - self.point += 1 + i for i, yx in enumerate(self.dtext.yx[self.point:], + self.point) + if yx[0] == self.cy) + self.move_to(new_point) + elif ch in {ctrl('h'), '\x7F', curses.KEY_BACKSPACE}: + if self.point > 0: + self.text = (self.text[:self.point - 1] + + self.text[self.point:]) + self.point -= 1 + elif ch in {ctrl('d'), curses.KEY_DC}: + if self.point < len(self.text): + self.text = (self.text[:self.point] + + self.text[self.point + 1:]) + elif ch in {'\r', '\n'}: + self.text = (self.text[:self.point] + '\n' + + self.text[self.point:]) + self.point += 1 + elif ch in {ctrl('o')}: + self.mode = 'ctrlo' + elif isinstance(ch, str) and (' ' <= ch < '\x7F' or '\xA0' <= ch): + # TODO: overwrite mode + self.text = (self.text[:self.point] + ch + + self.text[self.point:]) + self.point += 1 + elif self.mode == 'ctrlo': + if ch == ' ': + self.post() + self.cc.composer = None + self.cc.activity_stack.pop() + elif ch == 'q': + self.cc.composer = None + self.cc.activity_stack.pop() + else: + self.mode = 'normal' if not is_updown: self.goal_column = None + def post(self): + params = { + "status": self.text, + "visibility": "public", + "language": "en", # FIXME + } + if self.reply_id is not None: + params["in_reply_to_id"] = self.reply_id + self.cc.post("statuses", **params) + class testComposerLayout(unittest.TestCase): def testLayout(self): t = Composer.DisplayText("abc") -- 2.30.2