import signal
import sys
import threading
+import unittest
import client
+import scan_re
import text
import util
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))
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()
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:
'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")))
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)
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)]))