};
use super::types::InstanceStatusConfig;
use super::scan_re::Scan;
+use super::posting::{Post, PostMetadata};
struct EditorCore {
text: String,
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 {
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(),
goal_column: None,
header,
headersep: EditorHeaderSeparator::new(),
+ post_metadata: post.m,
}
}
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]
// 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
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;
}
}
-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)))
}
--- /dev/null
+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))
+}
use super::menu::*;
use super::file::*;
use super::editor::*;
+use super::posting::*;
fn ratatui_style_from_colour(colour: char) -> Style {
match colour {
Exit,
Nothing,
Error(ClientError), // throw UI into the Error Log
+ PostComposed(Post),
+ PostReEdit(Post),
}
pub trait ActivityState {
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) =>
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!(),
};
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 => {
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
+ }
}
}
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
}
}
- 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 {