chiark / gitweb /
First cut at an editor for composing posts.
authorSimon Tatham <anakin@pobox.com>
Thu, 7 Dec 2023 08:34:39 +0000 (08:34 +0000)
committerSimon Tatham <anakin@pobox.com>
Thu, 7 Dec 2023 18:25:47 +0000 (18:25 +0000)
Basic editing keys are supported; no refinements. Let's see how this
goes before I decide which editing functionality to add next.

cursesclient.py
mastodonochrome
text.py
util.py

index ad769f2a293ee8f032fa928f88faa4306900c14d..eb27c01d71f52b72d66ce93eeab41c2fcf908987 100644 (file)
@@ -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)]))
index 7df95f4b7e69c7169d94919fc86b4e53c8584a77..8e62161686ec5b4bfd70cfaa41eed856d802efb1 100755 (executable)
@@ -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 91e14c5a476843bb18f94f9ff95d7927ba1abbf0..9f124a2621bfa9448b9454090a9f08217c89b4e8 100644 (file)
--- 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 d5aeecae977481eca0e9143272935edee6314181..59c908dd42b3e03653206e6cdfe225ea793e4eaa 100644 (file)
--- 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