From: Simon Tatham Date: Tue, 2 Jan 2024 18:16:10 +0000 (+0000) Subject: Post-compose menu, up to but not including posting. X-Git-Url: https://www.chiark.greenend.org.uk/ucgi/~ian/git?a=commitdiff_plain;h=a2e67ade4f2992a1b0e2e95176645c6bf6a0a59e;p=mastodonochrome.git Post-compose menu, up to but not including posting. --- diff --git a/Cargo.toml b/Cargo.toml index daf2e3c..84977f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ regex = "1.10.2" reqwest = { version = "0.11.23", features = ["blocking"] } serde = { version = "1.0.193", features = ["derive"] } serde_json = "1.0.108" +strum = { version = "0.25.0", features = ["derive"] } unicode-width = "0.1.5" [target.'cfg(unix)'.dependencies] diff --git a/src/activity_stack.rs b/src/activity_stack.rs index d3178f4..e6ce569 100644 --- a/src/activity_stack.rs +++ b/src/activity_stack.rs @@ -7,6 +7,7 @@ pub enum NonUtilityActivity { SinglePost(String), HashtagTimeline(String), ComposeToplevel, + PostComposeMenu, } #[derive(PartialEq, Eq, Debug, Clone)] @@ -145,6 +146,13 @@ impl ActivityStack { _ => None, } } + + pub fn chain_to(&mut self, act: Activity) { + assert!(!self.overlay.is_some(), + "Don't expect to chain overlay actions"); + self.pop(); + self.goto(act); + } } #[test] diff --git a/src/editor.rs b/src/editor.rs index cc379c7..5f4612c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -11,6 +11,7 @@ use super::tui::{ }; use super::types::InstanceStatusConfig; use super::scan_re::Scan; +use super::posting::{Post, PostMetadata}; struct EditorCore { text: String, @@ -455,6 +456,8 @@ impl SingleLineEditor { return false; } + pub fn borrow_text(&self) -> &str { &self.core.text } + pub fn draw(&self, width: usize) -> (ColouredString, Option) { let mut s = self.prompt.clone(); if self.first_visible > 0 { @@ -751,16 +754,17 @@ struct Composer { goal_column: Option, header: FileHeader, headersep: EditorHeaderSeparator, + post_metadata: PostMetadata, } impl Composer { - fn new(conf: InstanceStatusConfig, header: FileHeader, - text: &str) -> Self + fn new(conf: InstanceStatusConfig, header: FileHeader, post: Post) -> Self { + let point = post.text.len(); Composer { core: EditorCore { - text: text.to_owned(), - point: text.len(), + text: post.text, + point, paste_buffer: "".to_owned(), }, regions: Vec::new(), @@ -774,6 +778,7 @@ impl Composer { goal_column: None, header, headersep: EditorHeaderSeparator::new(), + post_metadata: post.m, } } @@ -1121,6 +1126,36 @@ impl Composer { self.core.insert_after("\n"); } } + + fn insert_newline(&mut self) -> Option { + self.core.insert("\n"); + + let detect_magic_sequence = |seq: &str| { + self.core.text[..self.core.point].ends_with(seq) && + (self.core.point == seq.len() || + self.core.text[..self.core.point - seq.len()].ends_with("\n")) + }; + + if detect_magic_sequence(".\n") { + // The magic sequence! This terminates the editor and + // submits the buffer contents to whatever wanted it. + self.core.delete(self.core.point - 2, self.core.point); + Some(true) + } else if detect_magic_sequence(".quit\n") { + // The other magic sequence, which abandons the post. + Some(false) + } else { + // We've just normally inserted a newline. + None + } + } + + fn submit_post(&self) -> LogicalAction { + return LogicalAction::PostComposed(Post { + text: self.core.text.clone(), + m: self.post_metadata.clone(), + }) + } } #[test] @@ -1390,7 +1425,11 @@ impl ActivityState for Composer { // to handle it specially here, because EditorCore doesn't // handle it at all, because in BottomLineEditor it _is_ // special - (Start, Return) => self.core.insert("\n"), + (Start, Return) => match self.insert_newline() { + Some(true) => return self.submit_post(), + Some(false) => return LogicalAction::Pop, + None => (), + } // ^O is a prefix key that is followed by various less // common keystrokes @@ -1399,6 +1438,7 @@ impl ActivityState for Composer { return LogicalAction::Nothing; // no need to post_update } (CtrlO, Pr('q')) | (CtrlO, Pr('Q')) => return LogicalAction::Pop, + (CtrlO, Space) => return self.submit_post(), (CtrlO, _) => { self.keystate = Start; return LogicalAction::Beep; @@ -1412,11 +1452,11 @@ impl ActivityState for Composer { } } -pub fn compose_toplevel_post(client: &mut Client) -> +pub fn compose_toplevel_post(client: &mut Client, post: Post) -> 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, ""))) + Ok(Box::new(Composer::new(inst.configuration.statuses, header, post))) } diff --git a/src/lib.rs b/src/lib.rs index 2e7d73d..fc8379d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,4 @@ pub mod tui; pub mod menu; pub mod file; pub mod editor; +pub mod posting; diff --git a/src/posting.rs b/src/posting.rs new file mode 100644 index 0000000..c7e7dd6 --- /dev/null +++ b/src/posting.rs @@ -0,0 +1,346 @@ +use itertools::Itertools; +use std::cmp::max; +use strum::IntoEnumIterator; + +use super::client::Client; +use super::coloured_string::ColouredString; +use super::tui::{ + ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*, +}; +use super::text::*; +use super::types::Visibility; +use super::editor::SingleLineEditor; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PostMetadata { + pub in_reply_to_id: Option, + pub visibility: Visibility, + pub content_warning: Option, + pub language: String, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Post { + pub text: String, + pub m: PostMetadata, +} + +impl Post { + pub fn new() -> Self { + Post { + text: "".to_owned(), + m: PostMetadata { + in_reply_to_id: None, + visibility: Visibility::Public, + content_warning: None, + language: "en".to_owned(), // FIXME: better default + }, + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum PostMenuMode { + Normal, + EditCW, + EditLang, +} +use PostMenuMode::*; + +struct PostMenu { + post: Post, + title: FileHeader, + normal_status: FileStatusLineFinal, + edit_status: FileStatusLineFinal, + ml_post: MenuKeypressLine, + ml_cancel: MenuKeypressLine, + ml_edit: MenuKeypressLine, + ml_vis: MenuKeypressLine, + ml_content_warning: MenuKeypressLine, + ml_language: MenuKeypressLine, + mode: PostMenuMode, + editor: Option, + editor_prompt: Option, + last_width: usize, +} + +impl PostMenu { + fn new(post: Post) -> Self { + let title = match &post.m.in_reply_to_id { + None => "Post a toot".to_owned(), + Some(id) => format!("Reply to post id {id}"), + }; + let title = FileHeader::new(ColouredString::uniform(&title, 'H')); + + let normal_status = FileStatusLine::new() + .message("Select a menu option").finalise(); + let edit_status = FileStatusLine::new() + .message("Edit line and press Return").finalise(); + + let ml_post = MenuKeypressLine::new( + Space, ColouredString::plain("Post")); + let ml_cancel = MenuKeypressLine::new( + Pr('Q'), ColouredString::plain("Cancel post")); + let ml_edit = MenuKeypressLine::new( + Pr('A'), ColouredString::plain("Re-edit post")); + let ml_vis = Self::visibility_item(post.m.visibility); + let ml_content_warning = Self::content_warning_item( + post.m.content_warning.as_deref()); + let ml_language = Self::language_item(&post.m.language); + + let mut pm = PostMenu { + post, + title, + normal_status, + edit_status, + ml_post, + ml_cancel, + ml_edit, + ml_vis, + ml_content_warning, + ml_language, + mode: Normal, + editor: None, + editor_prompt: None, + last_width: 0, + }; + pm.fix_widths(); + pm + } + + fn fix_widths(&mut self) -> (usize, usize) { + let mut lmaxwid = 0; + let mut rmaxwid = 0; + let mut check_widths = |ml: &MenuKeypressLine| { + let (lwid, rwid) = ml.get_widths(); + lmaxwid = max(lmaxwid, lwid); + rmaxwid = max(rmaxwid, rwid); + }; + check_widths(&self.ml_post); + check_widths(&self.ml_cancel); + check_widths(&self.ml_edit); + check_widths(&self.ml_content_warning); + check_widths(&self.ml_language); + for vis in Visibility::iter() { + check_widths(&Self::visibility_item(vis)); + } + if let Some(ref ml) = self.editor_prompt { + check_widths(&ml); + } + + self.ml_post.reset_widths(); + self.ml_cancel.reset_widths(); + self.ml_edit.reset_widths(); + self.ml_vis.reset_widths(); + self.ml_content_warning.reset_widths(); + self.ml_language.reset_widths(); + if let Some(ref mut ml) = self.editor_prompt { + ml.reset_widths(); + } + + self.ml_post.ensure_widths(lmaxwid, rmaxwid); + self.ml_cancel.ensure_widths(lmaxwid, rmaxwid); + self.ml_edit.ensure_widths(lmaxwid, rmaxwid); + self.ml_vis.ensure_widths(lmaxwid, rmaxwid); + self.ml_content_warning.ensure_widths(lmaxwid, rmaxwid); + self.ml_language.ensure_widths(lmaxwid, rmaxwid); + if let Some(ref mut ml) = self.editor_prompt { + ml.ensure_widths(lmaxwid, rmaxwid); + } + + (lmaxwid, rmaxwid) + } + + fn visibility_item(vis: Visibility) -> MenuKeypressLine { + let text = match vis { + Visibility::Public => ColouredString::general( + "Visibility: public", + " ffffff"), + Visibility::Unlisted => ColouredString::general( + "Visibility: unlisted (anyone can see it, but feeds omit it)", + " rrrrrrrr "), + Visibility::Private => ColouredString::general( + "Visibility: private (followees and @mentioned users can see it)", + " rrrrrrr "), + Visibility::Direct => ColouredString::general( + "Visibility: direct (only @mentioned users can see it)", + " rrrrrr "), + }; + MenuKeypressLine::new(Pr('V'), text) + } + + fn content_warning_item(warning: Option<&str>) -> MenuKeypressLine { + let text = match warning { + None => ColouredString::general( + "Content warning: none", + " 0000"), + Some(text) => ColouredString::plain( + &format!("Content warning: {text}")), + }; + MenuKeypressLine::new(Pr('W'), text) + } + + fn language_item(lang: &str) -> MenuKeypressLine { + let text = ColouredString::plain(&format!("Language: {lang}")); + MenuKeypressLine::new(Pr('L'), text) + } + + fn cycle_visibility(&mut self) -> LogicalAction { + self.post.m.visibility = match self.post.m.visibility { + Visibility::Public => Visibility::Unlisted, + Visibility::Unlisted => Visibility::Private, + Visibility::Private => Visibility::Direct, + Visibility::Direct => Visibility::Public, + }; + self.ml_vis = Self::visibility_item(self.post.m.visibility); + self.fix_widths(); + LogicalAction::Nothing + } + + fn setup_editor(&mut self, prompt: MenuKeypressLine, text: String, + mode: PostMenuMode) { + self.editor_prompt = Some(prompt); + self.fix_widths(); + + let mut ed = SingleLineEditor::new(text); + ed.resize(self.last_width); + self.editor = Some(ed); + + self.mode = mode; + self.set_editor_prompt(); + } + + fn set_editor_prompt(&mut self) { + if let Some(ref mut ed) = &mut self.editor { + ed.set_prompt( + self.editor_prompt.as_ref().expect("editor goes with prompt") + .render(self.last_width) + .iter() + .exactly_one() + .expect("MenuKeypressLine should return exactly 1 line") + .clone()); + ed.resize(self.last_width); + } + } + + fn edit_content_warning(&mut self) -> LogicalAction { + let text = match &self.post.m.content_warning { + Some(s) => s.clone(), + None => "".to_owned(), + }; + self.setup_editor(Self::content_warning_item(Some("")), text, EditCW); + LogicalAction::Nothing + } + fn edit_language(&mut self) -> LogicalAction { + self.setup_editor(Self::language_item(""), + self.post.m.language.clone(), EditLang); + LogicalAction::Nothing + } +} + +impl ActivityState for PostMenu { + fn draw(&self, w: usize, h: usize) + -> (Vec, CursorPosition) { + let mut lines = Vec::new(); + let mut cursorpos = CursorPosition::End; + lines.extend_from_slice(&self.title.render(w)); + lines.extend_from_slice(&BlankLine::render_static()); + lines.extend_from_slice(&self.ml_post.render(w)); + lines.extend_from_slice(&self.ml_cancel.render(w)); + lines.extend_from_slice(&self.ml_edit.render(w)); + lines.extend_from_slice(&BlankLine::render_static()); + lines.extend_from_slice(&self.ml_vis.render(w)); + + if self.mode == EditCW { + let (text, cx) = self.editor.as_ref().unwrap().draw(w); + if let Some(cx) = cx { + cursorpos = CursorPosition::At(cx, lines.len()); + } + lines.push(text); + } else { + lines.extend_from_slice(&self.ml_content_warning.render(w)); + } + + if self.mode == EditLang { + let (text, cx) = self.editor.as_ref().unwrap().draw(w); + if let Some(cx) = cx { + cursorpos = CursorPosition::At(cx, lines.len()); + } + lines.push(text); + } else { + lines.extend_from_slice(&self.ml_language.render(w)); + } + + while lines.len() + 1 < h { + lines.extend_from_slice(&BlankLine::render_static()); + } + + lines.extend_from_slice(&match self.mode { + Normal => self.normal_status.render(w), + _ => self.edit_status.render(w), + }); + + (lines, cursorpos) + } + + fn handle_keypress(&mut self, key: OurKey, _client: &mut Client) -> + LogicalAction + { + match self.mode { + Normal => match key { + Space => LogicalAction::Beep, // FIXME: post + Pr('q') | Pr('Q') => LogicalAction::Pop, + Pr('a') | Pr('A') => LogicalAction::PostReEdit( + self.post.clone()), + Pr('v') | Pr('V') => self.cycle_visibility(), + Pr('w') | Pr('W') => self.edit_content_warning(), + Pr('l') | Pr('L') => self.edit_language(), + _ => LogicalAction::Nothing, + } + + EditCW => { + if self.editor.as_mut().unwrap().handle_keypress(key) { + self.post.m.content_warning = + match self.editor.as_ref().unwrap().borrow_text() { + "" => None, + s => Some(s.to_owned()), + }; + self.ml_content_warning = Self::content_warning_item( + self.post.m.content_warning.as_deref()); + + self.mode = Normal; + self.editor = None; + self.editor_prompt = None; + + self.fix_widths(); + } + LogicalAction::Nothing + } + + EditLang => { + if self.editor.as_mut().unwrap().handle_keypress(key) { + self.post.m.language = self.editor.as_ref().unwrap() + .borrow_text().to_owned(); + self.ml_language = Self::language_item( + &self.post.m.language); + + self.mode = Normal; + self.editor = None; + self.editor_prompt = None; + + self.fix_widths(); + } + LogicalAction::Nothing + } + } + } + + fn resize(&mut self, w: usize, _h: usize) { + self.last_width = w; + self.set_editor_prompt(); + } +} + +pub fn post_menu(post: Post) -> Box { + Box::new(PostMenu::new(post)) +} diff --git a/src/tui.rs b/src/tui.rs index af0fa27..17d7f9e 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -22,6 +22,7 @@ use super::config::ConfigLocation; use super::menu::*; use super::file::*; use super::editor::*; +use super::posting::*; fn ratatui_style_from_colour(colour: char) -> Style { match colour { @@ -389,6 +390,8 @@ pub enum LogicalAction { Exit, Nothing, Error(ClientError), // throw UI into the Error Log + PostComposed(Post), + PostReEdit(Post), } pub trait ActivityState { @@ -408,8 +411,8 @@ struct TuiLogicalState { last_area: Option, } -fn new_activity_state(activity: Activity, client: &mut Client) -> - Box +fn new_activity_state(activity: Activity, client: &mut Client, + post: Option) -> Box { let result = match activity { Activity::NonUtil(NonUtilityActivity::MainMenu) => @@ -445,7 +448,9 @@ fn new_activity_state(activity: Activity, client: &mut Client) -> Activity::NonUtil(NonUtilityActivity::SinglePost(ref id)) => view_single_post(client, id), Activity::NonUtil(NonUtilityActivity::ComposeToplevel) => - compose_toplevel_post(client), + compose_toplevel_post(client, post.unwrap_or_else(|| Post::new())), + Activity::NonUtil(NonUtilityActivity::PostComposeMenu) => + Ok(post_menu(post.expect("how did we get here without a Post?"))), _ => todo!(), }; @@ -552,12 +557,12 @@ impl TuiLogicalState { LogicalAction::Nothing => PhysicalAction::Nothing, LogicalAction::Goto(activity) => { self.activity_stack.goto(activity); - self.changed_activity(client); + self.changed_activity(client, None); PhysicalAction::Nothing } LogicalAction::Pop => { self.activity_stack.pop(); - self.changed_activity(client); + self.changed_activity(client, None); PhysicalAction::Nothing } LogicalAction::PopOverlaySilent => { @@ -569,6 +574,19 @@ impl TuiLogicalState { PhysicalAction::Beep } LogicalAction::Error(_) => PhysicalAction::Beep, // FIXME: Error Log + LogicalAction::PostComposed(post) => { + self.activity_stack.chain_to( + NonUtilityActivity::PostComposeMenu.into()); + self.changed_activity(client, Some(post)); + PhysicalAction::Nothing + } + LogicalAction::PostReEdit(post) => { + // FIXME: maybe not ComposeToplevel if we're replying + self.activity_stack.chain_to( + NonUtilityActivity::ComposeToplevel.into()); + self.changed_activity(client, Some(post)); + PhysicalAction::Nothing + } } } @@ -579,7 +597,7 @@ impl TuiLogicalState { if feeds_updated.contains(&FeedId::Mentions) { if self.activity_stack.top().throw_into_mentions() { self.activity_stack.goto(UtilityActivity::ReadMentions.into()); - self.changed_activity(client); + self.changed_activity(client, None); } // FIXME: we'd quite like a double-beep if you're in the composer @@ -589,11 +607,11 @@ impl TuiLogicalState { } } - fn changed_activity(&mut self, client: &mut Client) { + fn changed_activity(&mut self, client: &mut Client, post: Option) { self.activity_state = new_activity_state( - self.activity_stack.top(), client); + self.activity_stack.top(), client, post); self.overlay_activity_state = match self.activity_stack.overlay() { - Some(activity) => Some(new_activity_state(activity, client)), + Some(activity) => Some(new_activity_state(activity, client, None)), None => None, }; if let Some(area) = self.last_area { diff --git a/src/types.rs b/src/types.rs index 612dc3d..8414209 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize}; +use strum::EnumIter; use std::boxed::Box; use std::option::Option; @@ -119,7 +120,7 @@ pub struct Application { pub website: Option, } -#[derive(Deserialize, Debug, PartialEq, Eq, Clone, Copy)] +#[derive(Deserialize, Debug, PartialEq, Eq, Clone, Copy, EnumIter)] pub enum Visibility { #[serde(rename = "public")] Public, #[serde(rename = "unlisted")] Unlisted,