From 251b97830f58f6a38dfa97bba7413aa3660e122b Mon Sep 17 00:00:00 2001 From: Simon Tatham Date: Thu, 7 Dec 2023 18:44:20 +0000 Subject: [PATCH] Now we spawn an editor with the right set of recipients. Still can't _do_ anything with it, though. --- client.py | 10 +++ cursesclient.py | 169 +++++++++++++++++++++++++++++++++++------------- text.py | 8 ++- 3 files changed, 140 insertions(+), 47 deletions(-) diff --git a/client.py b/client.py index 38bf8dc..ebdf714 100644 --- a/client.py +++ b/client.py @@ -273,6 +273,8 @@ class Status: self.reply_id = data.get('in_reply_to_id') + self.mentions = data.get('mentions', []) + self.client = client def text(self): @@ -298,6 +300,11 @@ class Status: for media in self.media: yield text.Media(media['url'], media.get('description')) + def get_reply_recipients(self): + yield self.client.fq(self.account['acct']) + for mention in self.mentions: + yield self.client.fq(mention['acct']) + class Notification: def __init__(self, data, client): self.ntype = data.get('type') @@ -319,3 +326,6 @@ class Notification: yield text.NotificationLog( self.datestamp, self.client.fq(self.account['acct']), self.account['display_name'], self.ntype, self.content) + + def get_reply_recipients(self): + yield self.client.fq(self.account['acct']) diff --git a/cursesclient.py b/cursesclient.py index eb27c01..1e9c1f6 100644 --- a/cursesclient.py +++ b/cursesclient.py @@ -1,3 +1,4 @@ +import collections import curses import itertools import os @@ -157,6 +158,10 @@ class CursesUI(client.Client): self.composer = Composer(self) return self.composer + def new_composer(self, text): + self.composer = Composer(self, text) + return self.composer + def run(self): home_feed = self.home_timeline_feed() mentions_feed = self.mentions_feed() @@ -227,7 +232,19 @@ class CursesUI(client.Client): finally: self.curses_shutdown() -class Menu: +class Activity: + def chain_to(self, activity): + assert self.cc.activity_stack[-1] is self + if (len(self.cc.activity_stack) > 1 and + self.cc.activity_stack[-2] == activity): + self.cc.activity_stack.pop() + else: + self.cc.activity_stack[-1] = activity + + def push_to(self, activity): + self.cc.activity_stack.append(activity) + +class Menu(Activity): status_extra_text = None def __init__(self, cc): @@ -272,17 +289,6 @@ class Menu: def add_keys(self, sl): sl.keys.append(('RET', 'Back')) - def chain_to(self, activity): - assert self.cc.activity_stack[-1] is self - if (len(self.cc.activity_stack) > 1 and - self.cc.activity_stack[-2] == activity): - self.cc.activity_stack.pop() - else: - self.cc.activity_stack[-1] = activity - - def push_to(self, activity): - self.cc.activity_stack.append(activity) - def handle_key(self, ch): if ch in {'q', 'Q', '\n', '\r'}: return 'quit' @@ -392,29 +398,45 @@ class ExitMenu(Menu): else: return super().handle_key(ch) -class File: +class File(Activity): # Base class for anything where you page up and down. def __init__(self, cc): self.cc = cc + self.mode = 'normal' def handle_key(self, ch): - if ch in {' ', curses.KEY_NPAGE}: - self.down_screen() - elif ch in {'-', 'b', 'B', curses.KEY_PPAGE}: - self.up_screen() - elif ch == curses.KEY_DOWN: - self.down_line() - elif ch in {'\n', '\r'}: - if not self.down_line(): + if self.mode == 'normal': + if ch in {' ', curses.KEY_NPAGE}: + self.down_screen() + elif ch in {'-', 'b', 'B', curses.KEY_PPAGE}: + self.up_screen() + elif ch == curses.KEY_DOWN: + self.down_line() + elif ch in {'\n', '\r'}: + if not self.down_line(): + return 'quit' + elif ch in {curses.KEY_UP}: + self.up_line() + elif ch in {'0', curses.KEY_HOME}: + self.goto_top() + elif ch in {'z', 'Z', curses.KEY_END}: + self.goto_bottom() + elif ch in {'q', 'Q'}: return 'quit' - elif ch in {curses.KEY_UP}: - self.up_line() - elif ch in {'0', curses.KEY_HOME}: - self.goto_top() - elif ch in {'z', 'Z', curses.KEY_END}: - self.goto_bottom() - elif ch in {'q', 'Q'}: - return 'quit' + elif ch in {'s', 'S'}: + self.send_mode() + elif self.mode == 'send': + if ch in {'q', 'Q'}: + self.mode = 'normal' + elif ch in {'-', 'b', 'B', curses.KEY_UP}: + self.prev_send_target() + elif ch in {'+', curses.KEY_DOWN}: + self.next_send_target() + elif ch in {' ', 'i', 'I', 'a', 'A', 'l', 'L'}: + self.send_complete() + + def send_mode(self): + pass # not supported class ObjectFile(File): def __init__(self, cc, constructor, feed, title): @@ -435,6 +457,9 @@ class ObjectFile(File): self.lines = None self.linepos = None self.width = None + self.send_target = None + self.old_display_state = None + self.index_by_line = [] def iter_text_indexed(self): yield self.header, None @@ -464,14 +489,23 @@ class ObjectFile(File): return got_any def regenerate_lines(self, width): - if self.width == width and not self.fetch_new(): + display_state = (self.mode, self.send_target) + if (self.width == width and display_state == self.old_display_state and + not self.fetch_new()): return + self.old_display_state = display_state self.lines = [] + self.index_by_line = [] pos = 0 for thing, itemindex in self.iter_text_indexed(): - for line in thing.render(width): + params = {} + if (self.mode == 'send' and itemindex == self.send_target and + isinstance(thing, text.FromLine)): + params['target'] = True + for line in thing.render(width, **params): for s in line.split(width): self.lines.append(s) + self.index_by_line.append(itemindex) if itemindex == self.itempos: pos = len(self.lines) if self.width != width: @@ -485,15 +519,40 @@ class ObjectFile(File): self.cc.print_at(y, 0, line) sl = text.FileStatusLine() - if self.linepos >= len(self.lines): - sl.keys.append(('-', 'Up')) + if self.mode == 'send': + sl.keys.append(('SPACE', 'Reply')) + sl.keys.append(('-', None)) + sl.keys.append(('+', None)) + sl.keys.append(('Q', 'Quit')) else: - sl.keys.append(('SPACE', 'More')) - sl.keys.append(('Q', 'Exit')) - sl.proportion = self.linepos / len(self.lines) + if self.linepos >= len(self.lines): + sl.keys.append(('-', 'Up')) + else: + sl.keys.append(('SPACE', 'More')) + sl.keys.append(('Q', 'Exit')) + sl.proportion = self.linepos / len(self.lines) sl_rendered = util.exactly_one(sl.render(self.cc.scr_w)) self.cc.print_at(self.cc.scr_h - 1, 0, sl_rendered) + def send_mode(self): + self.mode = 'send' + self.send_target = self.index_by_line[self.linepos-1] + def prev_send_target(self): + self.send_target = max(self.minpos, self.send_target-1) + print("prev ->", self.send_target, file=sys.stderr) + def next_send_target(self): + self.send_target = min(self.maxpos-1, self.send_target+1) + 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))) + def move_to(self, pos): old_linepos = self.linepos self.linepos = pos @@ -615,6 +674,7 @@ class Composer: def layout(self, width): self.colourise() self.yx = [None] * (len(self.text) + 1) + self.yx[0] = 0, 0 # in case there's no text at all self.lines = [] y = 0 pos = 0 @@ -634,11 +694,11 @@ class Composer: y += 1 pos = next_nl + 1 - def __init__(self, cc): + def __init__(self, cc, initial_text=""): self.cc = cc self.header = text.FileHeader("Compose a post") - self.text = "@Lorem #ipsum dolor sit amet, @consectetur@adipisicing.elit, sed do eiusmod tempor https://incididunt.ut.com/labore/et/ dolore magna\naliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." - self.point = 95 + self.text = initial_text + self.point = len(self.text) self.goal_column = None def render(self): @@ -680,6 +740,18 @@ 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}: @@ -692,9 +764,12 @@ class Composer: if yx <= (self.cy + 1, self.goal_column))) except ValueError: new_point = len(self.text) - self.goal_column = None 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( @@ -702,9 +777,12 @@ class Composer: if yx <= (self.cy - 1, self.goal_column))) except ValueError: new_point = 0 - self.goal_column = None 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]) @@ -727,13 +805,11 @@ class Composer: self.text = (self.text[:self.point] + '\n' + self.text[self.point:]) self.point += 1 - elif isinstance(ch, str) and ' ' <= ch < '\x7F' or '\xA0' <= ch: + 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 - else: - pass # print("?", repr(ch), file=sys.stderr) if not is_updown: self.goal_column = None @@ -762,3 +838,8 @@ class testComposerLayout(unittest.TestCase): text.ColouredString("ixjkl")]) self.assertEqual(t.yx, ([(0,i) for i in range(10)] + [(1,i) for i in range(6)])) + + t = Composer.DisplayText("") + t.layout(10) + self.assertEqual(t.lines, []) + self.assertEqual(t.yx, [(0,0)]) diff --git a/text.py b/text.py index 9f124a2..a0be501 100644 --- a/text.py +++ b/text.py @@ -138,9 +138,9 @@ class UsernameHeader: self.account = account self.nameline = nameline - def render(self, width): + def render(self, width, target=False): # FIXME: truncate - yield (ColouredString(self.header + ": ") + + yield (ColouredString(self.header + ": ", 'f' if target else ' ') + ColouredString(f"{self.nameline} ({self.account})", self.colour)) @@ -246,7 +246,9 @@ class FileStatusLine: for key, action in self.keys: message += ( sep + ColouredString('[') + ColouredString(key, 'k') + - ColouredString(']:' + action)) + ColouredString(']')) + if action is not None: + message += ColouredString(':' + action) sep = sep2 if self.proportion is not None: message += sep + ColouredString('({:d}%)'.format( -- 2.30.2