+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,
})
))
}
+
+#[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<ComposeBufferRegion>,
+ layout: Vec<ComposeLayoutCell>,
+ 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<ComposeBufferRegion> {
+ let scanners = &[
+ ('#', &self.scanner.hashtag),
+ ('@', &self.scanner.mention),
+ ('u', &self.scanner.url),
+ ];
+
+ let mut regions: Vec<ComposeBufferRegion> = 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<ComposeLayoutCell> {
+ 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<ColouredString> {
+ // 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::<Vec<_>>());
+ assert_eq!(composer.layout(3),
+ (0..=3).map(|i| ComposeLayoutCell { pos: i, x: i, y: 0 })
+ .collect::<Vec<_>>());
+
+ // 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::<Vec<_>>());
+
+ // 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::<Vec<_>>());
+
+ // 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::<Vec<_>>());
+ // 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::<Vec<_>>());
+}
+
+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<ColouredString>, 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<Box<dyn ActivityState>, ClientError>
+{
+ let inst = client.instance()?;
+ let header = FileHeader::new(ColouredString::uniform(
+ "Compose a post", 'H'));
+ Ok(Box::new(Composer::new(inst.configuration.statuses, header, "")))
+}