From: Simon Tatham Date: Thu, 7 Dec 2023 08:34:39 +0000 (+0000) Subject: First cut at an editor for composing posts. X-Git-Url: https://www.chiark.greenend.org.uk/ucgi/~ian/git?a=commitdiff_plain;h=258e6d155ce5dd969593751bfce818212973fc92;p=mastodonochrome.git First cut at an editor for composing posts. Basic editing keys are supported; no refinements. Let's see how this goes before I decide which editing functionality to add next. --- diff --git a/cursesclient.py b/cursesclient.py index ad769f2..eb27c01 100644 --- a/cursesclient.py +++ b/cursesclient.py @@ -5,8 +5,10 @@ import select import signal import sys import threading +import unittest import client +import scan_re import text import util @@ -91,6 +93,9 @@ class CursesUI(client.Client): self.scr.addstr(y, x, frag, self.attrs[colour]) x += w + def move_cursor_to(self, y, x): + self.scr.move(y, x) + def clear(self): for y in range(self.scr_h): self.print_at(y, 0, text.ColouredString(' ' * self.scr_w)) @@ -147,6 +152,11 @@ class CursesUI(client.Client): curses.unget_wch(curses.KEY_RESIZE) self.selfpipes.append((sp, handler, None)) + def get_composer(self): + if self.composer is None: + self.composer = Composer(self) + return self.composer + def run(self): home_feed = self.home_timeline_feed() mentions_feed = self.mentions_feed() @@ -189,6 +199,8 @@ class CursesUI(client.Client): self.log_menu_2 = LogMenu2(self) self.exit_menu = ExitMenu(self) + self.composer = None # these are ephemeral, when we're writing a post + self.activity_stack = [self.main_menu] try: @@ -286,6 +298,10 @@ class MainMenu(Menu): 'H', text.ColouredString("Home timeline", "K "))) self.items.append(text.BlankLine()) + self.items.append(text.MenuKeypressLine( + 'C', text.ColouredString("Compose a post", + "K "))) + self.items.append(text.BlankLine()) self.items.append(text.MenuKeypressLine( 'ESC', text.ColouredString("Utilities and Exit"))) @@ -297,6 +313,8 @@ class MainMenu(Menu): def handle_key(self, ch): if ch in {'h', 'H'}: self.push_to(self.cc.home_timeline) + elif ch in {'c', 'C'}: + self.push_to(self.cc.get_composer()) else: return super().handle_key(ch) @@ -500,3 +518,247 @@ class StatusFile(ObjectFile): class NotificationsFile(ObjectFile): def __init__(self, cc, feed, title): super().__init__(cc, client.Notification, feed, title) + +class Composer: + class DisplayText: + def __init__(self, text): + self.text = text + self.regions = [] + + types = [('#', scan_re.hashtag), + ('@', scan_re.mention), + ('u', scan_re.url)] + + pos = 0 + while pos < len(text): + mstart = mend = len(text) + gotdesc = None + for desc, re in types: + match = re.search(self.text, pos=pos) + if match is not None and match.start() < mstart: + mstart, mend = match.span() + gotdesc = desc + + if pos < mstart: + self.regions.append((pos, mstart, ' ')) + if mstart < mend: + assert gotdesc is not None + self.regions.append((mstart, mend, gotdesc)) + + pos = mend + + def colourise(self): + self.cs = text.ColouredString("") + nchars = 0 + url_cost, char_limit = 23, 500 # FIXME: get this from the instance + for start, end, desc in self.regions: + region_len = end - start + if desc == 'u': + # URLs have a fixed length + region_len = url_cost + elif desc == '@': + # Fully qualified username mentions @foo@instance.domain + # only count for their local-part + mention = str(self.text)[start:end] + try: + mention = mention[:mention.index('@', 1)] + except ValueError: + pass + region_len = len(mention) + + if nchars > char_limit: + self.cs += text.ColouredString(self.text[start:end], '!') + elif nchars + region_len > char_limit: + nbad_chars = nchars + region_len - char_limit + nok_chars = max(0, end - start - nbad_chars) + self.cs += text.ColouredString( + self.text[start:start+nok_chars], desc) + self.cs += text.ColouredString( + self.text[start+nok_chars:end], '!') + else: + self.cs += text.ColouredString(self.text[start:end], desc) + + nchars += region_len + return self.cs + + def layout_para(self, width, para, startpos, y): + pos = 0 + while pos < len(para): + soft_wrap_point = hard_wrap_point = None + x = 0 + i = pos + while i <= len(para): + self.yx[startpos + i] = (y, x) + if i == len(para): + hard_wrap_point = soft_wrap_point = i + break + else: + x += para[i].width + if x > width: + break + + i += 1 + hard_wrap_point = i + if (i+1 < len(para) and + str(para[i-1]) == ' ' and + str(para[i]) != ' '): + soft_wrap_point = i + + assert hard_wrap_point is not None + wrap_point = (soft_wrap_point if soft_wrap_point is not None + else hard_wrap_point) + self.lines.append(para[pos:wrap_point]) + pos = wrap_point + y += 1 + return y + + def layout(self, width): + self.colourise() + self.yx = [None] * (len(self.text) + 1) + self.lines = [] + y = 0 + pos = 0 + csstr = str(self.cs) + while pos < len(self.cs): + try: + next_nl = csstr.index('\n', pos) + except ValueError: + next_nl = len(self.cs) + + yold = y + y = self.layout_para(width, self.cs[pos:next_nl], pos, y) + if y == yold: + # An empty paragraph should still show up + self.yx[pos] = y, 0 + self.lines.append(text.ColouredString("")) + y += 1 + pos = next_nl + 1 + + def __init__(self, cc): + 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.goal_column = None + + def render(self): + y = 0 + + for line in self.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 + + # FIXME: then, if there's a sendheader/file header, a whole + # row of ~~~~ followed by that + + for line in text.EditorHeaderSeparator().render(self.cc.scr_w): + self.cc.print_at(y, 0, line) + y += 1 + + self.dtext = self.DisplayText(self.text) + self.dtext.layout(self.cc.scr_w - 1) + + ytop = y + + for line in self.dtext.lines: + self.cc.print_at(y, 0, line) + y += 1 + + self.cy, self.cx = self.dtext.yx[self.point] + self.cc.move_cursor_to(self.cy + ytop, self.cx) + + def move_to(self, pos): + self.point = max(0, min(len(self.text), pos)) + + def handle_key(self, ch): + is_updown = ch in {ctrl('n'), ctrl('p'), + curses.KEY_DOWN, curses.KEY_UP} + if self.goal_column is None or not is_updown: + self.goal_column = self.cx + + 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.goal_column = None + + self.move_to(new_point) + 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.goal_column = None + + self.move_to(new_point) + 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 + else: + pass # print("?", repr(ch), file=sys.stderr) + + if not is_updown: + self.goal_column = None + +class testComposerLayout(unittest.TestCase): + def testLayout(self): + t = Composer.DisplayText("abc") + t.layout(10) + self.assertEqual(t.lines, [text.ColouredString("abc")]) + self.assertEqual(t.yx, [(0,i) for i in range(4)]) + + t.layout(3) + self.assertEqual(t.lines, [text.ColouredString("abc")]) + self.assertEqual(t.yx, [(0,i) for i in range(4)]) + + t = Composer.DisplayText("abc def ghi jkl") + t.layout(10) + self.assertEqual(t.lines, [text.ColouredString("abc def "), + text.ColouredString("ghi jkl")]) + self.assertEqual(t.yx, ([(0,i) for i in range(8)] + + [(1,i) for i in range(8)])) + + t = Composer.DisplayText("abcxdefxghixjkl") + t.layout(10) + self.assertEqual(t.lines, [text.ColouredString("abcxdefxgh"), + text.ColouredString("ixjkl")]) + self.assertEqual(t.yx, ([(0,i) for i in range(10)] + + [(1,i) for i in range(6)])) diff --git a/mastodonochrome b/mastodonochrome index 7df95f4..8e62161 100755 --- a/mastodonochrome +++ b/mastodonochrome @@ -66,12 +66,17 @@ class StreamUI(client.Client): class MyTestLoader(unittest.TestLoader): def loadTestsFromModule(self, module): suite = super().loadTestsFromModule(module) + if module.__name__ == '__main__': import text suite.addTests(super().loadTestsFromModule(text)) import scan_re suite.addTests(super().loadTestsFromModule(scan_re)) + + import cursesclient + suite.addTests(super().loadTestsFromModule(cursesclient)) + return suite def main(): diff --git a/text.py b/text.py index 91e14c5..9f124a2 100644 --- a/text.py +++ b/text.py @@ -32,6 +32,7 @@ colourmap = { 'H': [0, 36], # actual header text in file headers 'K': [0, 1, 36], # keypress / keypath names in file headers 'k': [0, 1], # keypresses in file status lines + '-': [0, 7, 40, 36], # separator line between editor header and content } class ColouredString: @@ -70,6 +71,9 @@ class ColouredString: def __iter__(self): return (ColouredString(sc, cc) for sc, cc in zip(self.s, self.c)) + def __getitem__(self, n): + return ColouredString(self.s[n], self.c[n]) + def __eq__(self, rhs): rhs = type(self)(rhs) return (self.s, self.c) == (rhs.s, rhs.c) @@ -125,6 +129,10 @@ class SeparatorLine: ColouredString("]--", 'S')) yield ColouredString("-", 'S') * (width - 1 - suffix.width) + suffix +class EditorHeaderSeparator: + def render(self, width): + yield ColouredString("-" * (width - 2) + "|", '-') + class UsernameHeader: def __init__(self, account, nameline): self.account = account diff --git a/util.py b/util.py index d5aeeca..59c908d 100644 --- a/util.py +++ b/util.py @@ -37,3 +37,13 @@ def exactly_one(stuff): except StopIteration: return toret raise ValueError("exactly_one got more than one") + +def last(stuff): + it = iter(stuff) + try: + toret = next(it) + except StopIteration: + raise ValueError("last of no things") + for thing in it: + toret = thing + return toret