From d1c75161fea1c0bacdcf25079e188acddca866bd Mon Sep 17 00:00:00 2001 From: Simon Tatham Date: Mon, 1 Jan 2024 16:13:15 +0000 Subject: [PATCH] Start of a full-screen editor for composing toots. You can't actually post anything. And none of the keystrokes special to the composing editor is implemented yet (ok, except [^O][Q] to quit the editor). But you can at least start typing text into the buffer, and it gets wrapped, colourised, and checked for being too long. --- src/activity_stack.rs | 1 + src/editor.rs | 546 +++++++++++++++++++++++++++++++++++++++++- src/menu.rs | 10 +- src/tui.rs | 4 +- 4 files changed, 550 insertions(+), 11 deletions(-) diff --git a/src/activity_stack.rs b/src/activity_stack.rs index 77bc551..d3178f4 100644 --- a/src/activity_stack.rs +++ b/src/activity_stack.rs @@ -6,6 +6,7 @@ pub enum NonUtilityActivity { LocalTimelineFile, SinglePost(String), HashtagTimeline(String), + ComposeToplevel, } #[derive(PartialEq, Eq, Debug, Clone)] diff --git a/src/editor.rs b/src/editor.rs index 2b1154c..bef31c6 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,12 +1,16 @@ +use std::cmp::{min, max}; use unicode_width::UnicodeWidthChar; use super::activity_stack::{NonUtilityActivity, UtilityActivity}; -use super::client::Client; +use super::client::{Client, ClientError}; use super::coloured_string::ColouredString; +use super::text::*; use super::tui::{ ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*, }; +use super::types::InstanceStatusConfig; +use super::scan_re::Scan; struct EditorCore { text: String, @@ -635,3 +639,543 @@ pub fn get_hashtag_to_read() -> Box { }) )) } + +#[derive(Debug, PartialEq, Eq, Clone)] +struct ComposeBufferRegion { + start: usize, + end: usize, + colour: char, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +struct ComposeLayoutCell { + pos: usize, + x: usize, + y: usize, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum ComposerKeyState { + Start, + CtrlO, +} + +struct Composer { + core: EditorCore, + regions: Vec, + layout: Vec, + scanner: &'static Scan, + conf: InstanceStatusConfig, + last_size: Option<(usize, usize)>, + keystate: ComposerKeyState, + header: FileHeader, + headersep: EditorHeaderSeparator, +} + +impl Composer { + fn new(conf: InstanceStatusConfig, header: FileHeader, + text: &str) -> Self + { + Composer { + core: EditorCore { + text: text.to_owned(), + point: text.len(), + paste_buffer: "".to_owned(), + }, + regions: Vec::new(), + layout: Vec::new(), + scanner: Scan::get(), + conf, + last_size: None, + keystate: ComposerKeyState::Start, + header, + headersep: EditorHeaderSeparator::new(), + } + } + + #[cfg(test)] + fn test_new(conf: InstanceStatusConfig, text: &str) -> Self { + Self::new(conf, FileHeader::new(ColouredString::plain("dummy")), text) + } + + fn is_line_boundary(c: char) -> bool { + // FIXME: for supporting multi-toot threads, perhaps introduce + // a second thing that functions as a toot-break, say \0, and + // regard it as a line boundary too for wrapping purposes + c == '\n' + } + + fn make_regions(&self, core: &EditorCore) -> Vec { + let scanners = &[ + ('#', &self.scanner.hashtag), + ('@', &self.scanner.mention), + ('u', &self.scanner.url), + ]; + + let mut regions: Vec = Vec::new(); + let mut pos = 0; + let mut nchars = 0; + + // Internal helper that we call whenever we decide what colour + // a region of the buffer ought to be, _after_ checking the + // character limit. Appends to 'regions', unless the region + // can be merged with the previous one. + let mut add_region_inner = |start: usize, end: usize, colour: char| { + // Special case: if the function below generates a + // zero-length region, don't bother to add it at all. + if end == start { + return + } + + // Check if there's a previous region with the same colour + if let Some(ref mut prev) = regions.last_mut() { + if prev.colour == colour { + assert_eq!(prev.end, start, "Regions should abut!"); + prev.end = end; + return; + } + } + + // Otherwise, just push a new region + regions.push(ComposeBufferRegion { start, end, colour }); + }; + + // Internal helper that we call whenever we decide what colour + // a region of the buffer ought to be, _irrespective_ of the + // character limit. Inside here, we check the character limit + // and may use it to adjust the colours we give to + // add_region_inner. + let mut add_region = |start: usize, end: usize, colour: char| { + // Determine the total cost of the current region. + let cost = match colour { + 'u' => self.conf.characters_reserved_per_url, + '@' => match self.core.text[start+1..end].find('@') { + Some(pos) => pos + 1, // just the part before the @domain + None => end - start, // otherwise the whole thing counts + } + _ => end - start, + }; + + // Maybe recolour the region as 'out of bounds', or partly so. + if nchars + cost <= self.conf.max_characters { + // Whole region is in bounds + add_region_inner(start, end, colour); + } else if nchars >= self.conf.max_characters { + // Whole region is out of bounds + add_region_inner(start, end, '!'); + } else { + // Break the region into an ok and a bad subsection + let nbad_chars = nchars + cost - self.conf.max_characters; + let nok_chars = end.saturating_sub(start + nbad_chars); + let midpoint = start + nok_chars; + add_region_inner(start, midpoint, colour); + add_region_inner(midpoint, end, '!'); + } + + nchars += cost; + }; + + while pos < core.text.len() { + // Try all three regex matchers and see which one picks up + // something starting soonest (out of those that pick up + // anything at all). + let next_match = scanners.iter().filter_map(|(colour, scanner)| { + scanner.get_span(&core.text[pos..]) + .map(|(start, end)| (pos + start, pos + end, colour)) + }).min(); + + match next_match { + Some((start, end, colour)) => { + if pos < start { + add_region(pos, start, ' '); + } + if start < end { + add_region(start, end, *colour); + } + pos = end; + } + None => { + add_region(pos, core.text.len(), ' '); + break; + } + } + } + + regions + } + + fn layout(&self, width: usize) -> Vec { + let mut cells = Vec::new(); + let mut pos = 0; + let mut x = 0; + let mut y = 0; + let mut soft_wrap_pos = None; + let mut hard_wrap_pos = None; + + loop { + cells.push(ComposeLayoutCell{pos, x, y}); + match self.core.char_width_and_bytes(pos) { + None => break, // we're done + Some((w, b)) => { + let mut chars_iter = self.core.text[pos..].chars(); + let c = chars_iter.next() + .expect("we just found out we're not at end of string"); + if Self::is_line_boundary(c) { + // End of paragraph. + y += 1; + x = 0; + soft_wrap_pos = None; + hard_wrap_pos = None; + pos += b; + } else { + x += w; + if x <= width { + pos += b; + hard_wrap_pos = Some((pos, cells.len())); + if c == ' ' && chars_iter.next() != Some(' ') { + soft_wrap_pos = hard_wrap_pos; + } + } else { + let (wrap_pos, wrap_cell) = match soft_wrap_pos { + Some(p) => p, + None => hard_wrap_pos.expect( + "We can't break the line _anywhere_?!"), + }; + + // Now rewind to the place we just broke + // the line, and keep going + pos = wrap_pos; + cells.truncate(wrap_cell); + y += 1; + x = 0; + soft_wrap_pos = None; + hard_wrap_pos = None; + } + } + } + } + } + + cells + } + + fn get_coloured_line(&self, y: usize) -> Option { + // Use self.layout to find the bounds of this line within the + // buffer text. + let start_cell_index = self.layout + .partition_point(|cell| cell.y < y); + if dbg!(start_cell_index) == self.layout.len() { + return None; // y is after the end of the buffer + } + let start_pos = self.layout[start_cell_index].pos; + + let end_cell_index = self.layout + .partition_point(|cell| cell.y <= y); + let end_pos = if end_cell_index == self.layout.len() { + self.core.text.len() + } else { + self.layout[end_cell_index].pos + }; + + // Trim the line-end character from the bounds if there is + // one: we don't want to _draw_ it, under any circumstances. + let last_char = if end_cell_index > start_cell_index { + let last_cell = &self.layout[end_cell_index - 1]; + self.core.text[last_cell.pos..].chars().next() + } else { + None + }; + let end_pos = if last_char.map_or(false, Self::is_line_boundary) { + end_pos - 1 + } else { + end_pos + }; + + // Now look up in self.regions to decide what colour to make + // everything. + let start_region_index = self.regions + .partition_point(|region| region.end <= start_pos); + + let mut cs = ColouredString::plain(""); + + for region in self.regions[start_region_index..].iter() { + if end_pos <= region.start { + break; // finished this line + } + let start = max(start_pos, region.start); + let end = min(end_pos, region.end); + cs.push_str(&ColouredString::uniform( + &self.core.text[start..end], region.colour).slice()); + } + + Some(cs) + } + + fn post_update(&mut self) { + self.regions = self.make_regions(&self.core); + if let Some((w, _h)) = self.last_size { + self.layout = self.layout(w); + } + } +} + +#[test] +fn test_regions() { + let standard_conf = InstanceStatusConfig { + max_characters: 500, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }; + let main_sample_text = "test a #hashtag and a @mention@domain.thingy and a https://url.url.url.example.com/whatnot."; + + // Scan the sample text and ensure we're spotting the hashtag, + // mention and URL. + let composer = Composer::test_new(standard_conf.clone(), main_sample_text); + assert_eq!(composer.make_regions(&composer.core), vec! { + ComposeBufferRegion { start: 0, end: 7, colour: ' ' }, + ComposeBufferRegion { start: 7, end: 15, colour: '#' }, + ComposeBufferRegion { start: 15, end: 22, colour: ' ' }, + ComposeBufferRegion { start: 22, end: 44, colour: '@' }, + ComposeBufferRegion { start: 44, end: 51, colour: ' ' }, + ComposeBufferRegion { start: 51, end: 90, colour: 'u' }, + ComposeBufferRegion { start: 90, end: 91, colour: ' ' }, + }); + + // Scan a shorter piece of text in which a hashtag and a mention + // directly abut. FIXME: actually, surely we _shouldn't_ match + // this? + let composer = Composer::test_new( + standard_conf.clone(), "#hashtag@mention"); + assert_eq!(composer.make_regions(&composer.core), vec! { + ComposeBufferRegion { start: 0, end: 8, colour: '#' }, + ComposeBufferRegion { start: 8, end: 16, colour: '@' }, + }); + + // The total cost of main_sample_text is 61 (counting the mention + // and the URL for less than their full lengths). So setting + // max=60 highlights the final character as overflow. + let composer = Composer::test_new(InstanceStatusConfig { + max_characters: 60, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, main_sample_text); + assert_eq!(composer.make_regions(&composer.core), vec! { + ComposeBufferRegion { start: 0, end: 7, colour: ' ' }, + ComposeBufferRegion { start: 7, end: 15, colour: '#' }, + ComposeBufferRegion { start: 15, end: 22, colour: ' ' }, + ComposeBufferRegion { start: 22, end: 44, colour: '@' }, + ComposeBufferRegion { start: 44, end: 51, colour: ' ' }, + ComposeBufferRegion { start: 51, end: 90, colour: 'u' }, + ComposeBufferRegion { start: 90, end: 91, colour: '!' }, + }); + + // Dropping the limit by another 1 highlights the last character + // of the URL. + // them.) + let composer = Composer::test_new(InstanceStatusConfig { + max_characters: 59, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, main_sample_text); + assert_eq!(composer.make_regions(&composer.core), vec! { + ComposeBufferRegion { start: 0, end: 7, colour: ' ' }, + ComposeBufferRegion { start: 7, end: 15, colour: '#' }, + ComposeBufferRegion { start: 15, end: 22, colour: ' ' }, + ComposeBufferRegion { start: 22, end: 44, colour: '@' }, + ComposeBufferRegion { start: 44, end: 51, colour: ' ' }, + ComposeBufferRegion { start: 51, end: 90-1, colour: 'u' }, + ComposeBufferRegion { start: 90-1, end: 91, colour: '!' }, + }); + + // and dropping it by another 21 highlights the last 22 characters + // of the URL ... + let composer = Composer::test_new(InstanceStatusConfig { + max_characters: 38, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, main_sample_text); + assert_eq!(composer.make_regions(&composer.core), vec! { + ComposeBufferRegion { start: 0, end: 7, colour: ' ' }, + ComposeBufferRegion { start: 7, end: 15, colour: '#' }, + ComposeBufferRegion { start: 15, end: 22, colour: ' ' }, + ComposeBufferRegion { start: 22, end: 44, colour: '@' }, + ComposeBufferRegion { start: 44, end: 51, colour: ' ' }, + ComposeBufferRegion { start: 51, end: 90-22, colour: 'u' }, + ComposeBufferRegion { start: 90-22, end: 91, colour: '!' }, + }); + + // but dropping it by _another_ one means that the entire URL + // (since it costs 23 chars no matter what its length) is beyond + // the limit, so now it all gets highlighted. + let composer = Composer::test_new(InstanceStatusConfig { + max_characters: 37, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, main_sample_text); + assert_eq!(composer.make_regions(&composer.core), vec! { + ComposeBufferRegion { start: 0, end: 7, colour: ' ' }, + ComposeBufferRegion { start: 7, end: 15, colour: '#' }, + ComposeBufferRegion { start: 15, end: 22, colour: ' ' }, + ComposeBufferRegion { start: 22, end: 44, colour: '@' }, + ComposeBufferRegion { start: 44, end: 51, colour: ' ' }, + ComposeBufferRegion { start: 51, end: 91, colour: '!' }, + }); + + // And just for good measure, drop the limit by one _more_, and show the ordinary character just before the URL being highlighted as well. + let composer = Composer::test_new(InstanceStatusConfig { + max_characters: 36, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, main_sample_text); + assert_eq!(composer.make_regions(&composer.core), vec! { + ComposeBufferRegion { start: 0, end: 7, colour: ' ' }, + ComposeBufferRegion { start: 7, end: 15, colour: '#' }, + ComposeBufferRegion { start: 15, end: 22, colour: ' ' }, + ComposeBufferRegion { start: 22, end: 44, colour: '@' }, + ComposeBufferRegion { start: 44, end: 51-1, colour: ' ' }, + ComposeBufferRegion { start: 51-1, end: 91, colour: '!' }, + }); +} + +#[test] +fn test_layout() { + let conf = InstanceStatusConfig { + max_characters: 500, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }; + + // The empty string, because it would be embarrassing if that didn't work + let composer = Composer::test_new(conf.clone(), ""); + assert_eq!(composer.layout(10), vec!{ + ComposeLayoutCell { pos: 0, x: 0, y: 0 }}); + + // One line, just to check that we get a position assigned to + // every character boundary + let composer = Composer::test_new(conf.clone(), "abc"); + assert_eq!(composer.layout(10), + (0..=3).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }) + .collect::>()); + assert_eq!(composer.layout(3), + (0..=3).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }) + .collect::>()); + + // Two lines, which wrap so that 'g' is first on the new line + let composer = Composer::test_new(conf.clone(), "abc def ghi jkl"); + assert_eq!( + composer.layout(10), + (0..=7).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }).chain( + (0..=7).map(|i| ComposeLayoutCell { pos: i+8, x: i, y: 1 })) + .collect::>()); + + // An overlong line, which has to wrap via the fallback + // hard_wrap_pos system, so we get the full 10 characters on the + // first line + let composer = Composer::test_new(conf.clone(), "abcxdefxghixjkl"); + assert_eq!( + composer.layout(10), + (0..=9).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }).chain( + (0..=5).map(|i| ComposeLayoutCell { pos: i+10, x: i, y: 1 })) + .collect::>()); + + // The most trivial case with a newline in: _just_ the newline + let composer = Composer::test_new(conf.clone(), "\n"); + assert_eq!(composer.layout(10), vec!{ + ComposeLayoutCell { pos: 0, x: 0, y: 0 }, + ComposeLayoutCell { pos: 1, x: 0, y: 1 }}); + + // Watch what happens just as we type text across a wrap boundary. + // At 8 characters, this should be fine as it is, since the wrap + // width is 1 less than the physical screen width and the cursor + // is permitted to be in the final column if it's at end-of-buffer + // rather than on a character. + let composer = Composer::test_new(conf.clone(), "abc def "); + assert_eq!( + composer.layout(8), + (0..=8).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }) + .collect::>()); + // Now we type the next character, and it should wrap on to the + // next line. + let composer = Composer::test_new(conf.clone(), "abc def g"); + assert_eq!( + composer.layout(8), + (0..=7).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 }).chain( + (0..=1).map(|i| ComposeLayoutCell { pos: i+8, x: i, y: 1 })) + .collect::>()); +} + +impl ActivityState for Composer { + fn resize(&mut self, w: usize, h: usize) { + if self.last_size != Some((w, h)) { + self.last_size = Some((w, h)); + self.post_update(); + } + } + + fn draw(&self, w: usize, h: usize) -> (Vec, CursorPosition) + { + let mut lines = Vec::new(); + lines.extend_from_slice(&self.header.render(w)); + lines.extend_from_slice(&self.headersep.render(w)); + + let ytop = 0; // FIXME: vary this to keep cursor in view + let ystart = lines.len(); + + while lines.len() < h { + let y = ytop + (lines.len() - ystart); + match self.get_coloured_line(y) { + Some(line) => lines.push(line), + None => break, // ran out of lines in the buffer + } + } + + let mut cursor_pos = CursorPosition::None; + let cursor_cell_index = self.layout + .partition_point(|cell| cell.pos < self.core.point); + if let Some(cell) = self.layout.get(cursor_cell_index) { + if cell.pos == self.core.point { + if let Some(y) = cell.y.checked_sub(ytop) { + let y = y + ystart; + if y < h { + cursor_pos = CursorPosition::At(cell.x, y); + } + } + } + } + + (lines, cursor_pos) + } + + fn handle_keypress(&mut self, key: OurKey, _client: &mut Client) -> + LogicalAction + { + use ComposerKeyState::*; + + match (self.keystate, key) { + (Start, Ctrl('O')) => { + self.keystate = CtrlO; + } + (CtrlO, Pr('q')) | (CtrlO, Pr('Q')) => { + return LogicalAction::Pop; + } + (CtrlO, _) => { + self.keystate = Start; + return LogicalAction::Beep; + } + (Start, _) => { + self.core.handle_keypress(key); + self.post_update(); + } + } + LogicalAction::Nothing + } +} + +pub fn compose_toplevel_post(client: &mut Client) -> + Result, ClientError> +{ + let inst = client.instance()?; + let header = FileHeader::new(ColouredString::uniform( + "Compose a post", 'H')); + Ok(Box::new(Composer::new(inst.configuration.statuses, header, ""))) +} diff --git a/src/menu.rs b/src/menu.rs index ba256d2..f9b57ea 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -73,12 +73,6 @@ impl Menu { fn add_action_coloured(&mut self, key: OurKey, desc: ColouredString, action: LogicalAction) { - let desc = if action == LogicalAction::NYI { - desc + &ColouredString::plain(" ") + - &ColouredString::uniform("NYI", '!') - } else { - desc - }; self.lines.push(MenuLine::Key(MenuKeypressLine::new(key, desc))); if action != LogicalAction::Nothing { @@ -201,8 +195,8 @@ pub fn main_menu() -> Box { menu.add_action(Pr('I'), "View a post by its ID", LogicalAction::Goto( OverlayActivity::GetPostIdToRead.into())); menu.add_blank_line(); - menu.add_action(Pr('C'), "Compose a post", - LogicalAction::NYI); + menu.add_action(Pr('C'), "Compose a post", LogicalAction::Goto( + NonUtilityActivity::ComposeToplevel.into())); menu.add_blank_line(); // We don't need to provide a LogicalAction for this keystroke, diff --git a/src/tui.rs b/src/tui.rs index 9a9b787..70c55cc 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -397,7 +397,6 @@ pub enum LogicalAction { Exit, Nothing, Error(ClientError), // throw UI into the Error Log - NYI, // FIXME: get rid of this once everything is implemented } pub trait ActivityState { @@ -453,6 +452,8 @@ fn new_activity_state(activity: Activity, client: &mut Client) -> examine_user(client, name), Activity::NonUtil(NonUtilityActivity::SinglePost(ref id)) => view_single_post(client, id), + Activity::NonUtil(NonUtilityActivity::ComposeToplevel) => + compose_toplevel_post(client), _ => todo!(), }; @@ -557,7 +558,6 @@ impl TuiLogicalState { LogicalAction::Beep => PhysicalAction::Beep, LogicalAction::Exit => PhysicalAction::Exit, LogicalAction::Nothing => PhysicalAction::Nothing, - LogicalAction::NYI => PhysicalAction::Beep, LogicalAction::Goto(activity) => { self.activity_stack.goto(activity); self.changed_activity(client); -- 2.30.2