chiark / gitweb /
Post-compose menu, up to but not including posting.
authorSimon Tatham <anakin@pobox.com>
Tue, 2 Jan 2024 18:16:10 +0000 (18:16 +0000)
committerSimon Tatham <anakin@pobox.com>
Wed, 3 Jan 2024 07:21:53 +0000 (07:21 +0000)
Cargo.toml
src/activity_stack.rs
src/editor.rs
src/lib.rs
src/posting.rs [new file with mode: 0644]
src/tui.rs
src/types.rs

index daf2e3ce6f13b0b5b3955ba4e9aa0ca1499a5841..84977f07664ed7389636adc692dd18061c8d5391 100644 (file)
@@ -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]
index d3178f44699fd2036ed40b35108a91141e1391f2..e6ce5699ad60ac670dc199a0b5008c9303186cc9 100644 (file)
@@ -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]
index cc379c7e69fb897ba0121b901bf879ef7ba78057..5f4612c5cfe816f55c039817c7795bff404a3d91 100644 (file)
@@ -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<usize>) {
         let mut s = self.prompt.clone();
         if self.first_visible > 0 {
@@ -751,16 +754,17 @@ struct Composer {
     goal_column: Option<usize>,
     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<bool> {
+        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<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, "")))
+    Ok(Box::new(Composer::new(inst.configuration.statuses, header, post)))
 }
index 2e7d73d690c9b1370082b3bc7cf3e2e0c6a9cc83..fc8379daa97a0d5c7eabf94298fe887e3cd16b6e 100644 (file)
@@ -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 (file)
index 0000000..c7e7dd6
--- /dev/null
@@ -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<String>,
+    pub visibility: Visibility,
+    pub content_warning: Option<String>,
+    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<SingleLineEditor>,
+    editor_prompt: Option<MenuKeypressLine>,
+    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<ColouredString>, 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<dyn ActivityState> {
+    Box::new(PostMenu::new(post))
+}
index af0fa27451d524d35a4fe9402c2622ff8be36264..17d7f9e2c700af2084a774b925b85782bc331bb6 100644 (file)
@@ -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<Rect>,
 }
 
-fn new_activity_state(activity: Activity, client: &mut Client) ->
-    Box<dyn ActivityState>
+fn new_activity_state(activity: Activity, client: &mut Client,
+                      post: Option<Post>) -> Box<dyn ActivityState>
 {
     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<Post>) {
         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 {
index 612dc3d7996571bc41af91bd632a4fca7caefc94..8414209f461530c2ed51de32adf4ca59b605ee5b 100644 (file)
@@ -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<String>,
 }
 
-#[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,