From aa5f8ff4bce898907ffc0c0e2b7ea36d7f8c10b7 Mon Sep 17 00:00:00 2001 From: Andy Wortman Date: Wed, 8 Nov 2017 02:05:09 -0800 Subject: first brush of a compose mode, support thread viewing again --- src/commands/mod.rs | 16 +++--- src/commands/twete.rs | 122 +++++++++++++++++++++++++------------------ src/display/mod.rs | 142 +++++++++++++++++++++++++++++++++++++++++++------- src/main.rs | 122 ++++++++++++++++++++++++++++++++++++++----- src/tw/mod.rs | 17 +++++- 5 files changed, 327 insertions(+), 92 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 49b2cba..9ec6c4b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,14 +7,14 @@ pub struct Command { pub exec: fn(line: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) } -mod show_cache; -mod twete; -mod look_up; -mod view; -mod quit; -mod fav; -mod follow; -mod thread; +pub mod show_cache; +pub mod twete; +pub mod look_up; +pub mod view; +pub mod quit; +pub mod fav; +pub mod follow; +pub mod thread; pub static COMMANDS: &[&Command] = &[ &show_cache::SHOW_CACHE, diff --git a/src/commands/twete.rs b/src/commands/twete.rs index f057e5f..4452df9 100644 --- a/src/commands/twete.rs +++ b/src/commands/twete.rs @@ -36,13 +36,23 @@ fn del(line: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { pub static TWETE: Command = Command { keyword: "t", - params: 1, + params: 0, exec: twete }; fn twete(line: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { - let text = line.trim(); - let substituted = ::url_encode(text); + // if there's text, send it. + // if it's just "t", enter compose mode. + let text = line.trim().to_owned(); + if text.len() == 0 { + tweeter.display_info.mode = Some(::display::DisplayMode::Compose(text)); + } else { + send_twete(text, tweeter, queryer); + } +} + +pub fn send_twete(text: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { + let substituted = ::url_encode(&text); if text.len() <= 140 { match queryer.do_api_post(&format!("{}?status={}", CREATE_TWEET_URL, substituted)) { Ok(_) => (), @@ -61,6 +71,8 @@ pub static THREAD: Command = Command { exec: thread }; +// the difference between threading and replying is not including +// yourself in th @'s. fn thread(line: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { let mut text: String = line.trim().to_string(); if let Some(id_end_idx) = text.find(" ") { @@ -75,11 +87,7 @@ fn thread(line: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { let handle = &tweeter.retrieve_user(&twete.author_id).unwrap().handle.to_owned(); // TODO: definitely breaks if you change your handle right now if handle == &tweeter.current_user.handle { - let substituted = ::url_encode(reply); - match queryer.do_api_post(&format!("{}?status={}&in_reply_to_status_id={}", CREATE_TWEET_URL, substituted, twete.id)) { - Ok(_) => (), - Err(e) => tweeter.display_info.status(e) - } + send_reply(reply.to_owned(), twid, tweeter, queryer); } else { tweeter.display_info.status("you can only thread your own tweets".to_owned()); // ask if it should .@ instead? @@ -100,58 +108,70 @@ fn thread(line: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { pub static REP: Command = Command { keyword: "rep", - params: 2, + params: 1, exec: rep }; fn rep(line: String, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { let mut text: String = line.trim().to_string(); - if let Some(id_end_idx) = text.find(" ") { - let reply_bare = text.split_off(id_end_idx + 1); - let reply = reply_bare.trim(); - let id_str = text.trim(); - if reply.len() > 0 { - let maybe_id = TweetId::parse(id_str.to_owned()); - match maybe_id { - Ok(twid) => { - if let Some(twete) = tweeter.retrieve_tweet(&twid).map(|x| x.clone()) { // TODO: no clone when this stops taking &mut self - // get handles to reply to... - let author_handle = tweeter.retrieve_user(&twete.author_id).unwrap().handle.to_owned(); - let mut ats: Vec = twete.get_mentions(); //std::collections::HashSet::new(); - /* - for handle in twete.get_mentions() { - ats.insert(handle); - } - */ - ats.remove_item(&author_handle); - ats.insert(0, author_handle); - if let Some(rt_tweet) = twete.rt_tweet.and_then(|id| tweeter.retrieve_tweet(&TweetId::Twitter(id))).map(|x| x.clone()) { - let rt_author_handle = tweeter.retrieve_user(&rt_tweet.author_id).unwrap().handle.to_owned(); - ats.remove_item(&rt_author_handle); - ats.insert(1, rt_author_handle); - } - //let ats_vec: Vec<&str> = ats.into_iter().collect(); - //let full_reply = format!("{} {}", ats_vec.join(" "), reply); - let decorated_ats: Vec = ats.into_iter().map(|x| format!("@{}", x)).collect(); - let full_reply = format!("{} {}", decorated_ats.join(" "), reply); - let substituted = ::url_encode(&full_reply); - match queryer.do_api_post(&format!("{}?status={}&in_reply_to_status_id={}", CREATE_TWEET_URL, substituted, twete.id)) { - Ok(_) => (), - Err(e) => tweeter.display_info.status(e) - } - } else { - tweeter.display_info.status(format!("No tweet for id: {:?}", twid)); - } - }, - Err(e) => { - tweeter.display_info.status(format!("Cannot parse input: {:?} ({})", id_str, e)); + let reply_bare = match text.find(" ") { + None => "".to_owned(), + Some(id_end_idx) => { + text.split_off(id_end_idx + 1) + } + }; + let reply = reply_bare.trim(); + let id_str = text.trim(); + let maybe_id = TweetId::parse(id_str.to_owned()); + match maybe_id { + Ok(twid) => { + if let Some(twete) = tweeter.retrieve_tweet(&twid).map(|x| x.clone()) { // TODO: no clone when this stops taking &mut self + // get handles to reply to... + let author_handle = tweeter.retrieve_user(&twete.author_id).unwrap().handle.to_owned(); + let mut ats: Vec = twete.get_mentions(); //std::collections::HashSet::new(); + ats.remove_item(&author_handle); + ats.insert(0, author_handle); + if let Some(rt_tweet) = twete.rt_tweet.and_then(|id| tweeter.retrieve_tweet(&TweetId::Twitter(id))).map(|x| x.clone()) { + let rt_author_handle = tweeter.retrieve_user(&rt_tweet.author_id).unwrap().handle.to_owned(); + ats.remove_item(&rt_author_handle); + ats.insert(1, rt_author_handle); } + + // if you're directly replying to yourself, i trust you know what you're doing and + // want to @ yourself again (this keeps self-replies from showing up on your + // profile as threaded tweets, f.ex) + if !(ats.len() > 0 && &ats[0] == &tweeter.current_user.handle) { + ats.remove_item(&tweeter.current_user.handle); + } + //let ats_vec: Vec<&str> = ats.into_iter().collect(); + //let full_reply = format!("{} {}", ats_vec.join(" "), reply); + let decorated_ats: Vec = ats.into_iter().map(|x| format!("@{}", x)).collect(); + let full_reply = format!("{} {}", decorated_ats.join(" "), reply); + + if reply.len() > 0 { + send_reply(full_reply, twid, tweeter, queryer); + } else { + tweeter.display_info.mode = Some(::display::DisplayMode::Reply(twid, full_reply)); + } + } else { + tweeter.display_info.status(format!("No tweet for id: {:?}", twid)); } - } else { - tweeter.display_info.status("rep your sik reply".to_owned()); + }, + Err(e) => { + tweeter.display_info.status(format!("Cannot parse input: {:?} ({})", id_str, e)); + } + } +} + +pub fn send_reply(text: String, twid: TweetId, tweeter: &mut tw::TwitterCache, queryer: &mut Queryer) { + if let Some(twete) = tweeter.retrieve_tweet(&twid).map(|x| x.clone()) { // TODO: no clone when this stops taking &mut self + let substituted = ::url_encode(&text); + match queryer.do_api_post(&format!("{}?status={}&in_reply_to_status_id={}", CREATE_TWEET_URL, substituted, twete.id)) { + Ok(_) => (), + Err(e) => tweeter.display_info.status(e) } } else { - tweeter.display_info.status("rep your sik reply".to_owned()); + tweeter.display_info.status(format!("Tweet stopped existing: {:?}", twid)); } } diff --git a/src/display/mod.rs b/src/display/mod.rs index 73a1e09..4480855 100644 --- a/src/display/mod.rs +++ b/src/display/mod.rs @@ -13,6 +13,12 @@ use ::tw::TweetId; use std; #[derive(Clone)] +pub enum DisplayMode { + Compose(String), + Reply(TweetId, String) +} + +#[derive(Clone)] pub enum Infos { Tweet(TweetId), TweetWithContext(TweetId, String), @@ -22,20 +28,29 @@ pub enum Infos { User(tw::user::User) } +const COMPOSE_HEIGHT: u16 = 5; pub struct DisplayInfo { + pub log_height: u16, + pub prompt_height: u16, + pub mode: Option, pub log_seek: u32, pub infos_seek: u32, pub log: Vec, - pub infos: Vec + pub infos: Vec, + pub input_buf: Vec } impl Default for DisplayInfo { fn default() -> Self { DisplayInfo { + log_height: 4, + prompt_height: 3, + mode: None, log_seek: 0, infos_seek: 0, log: Vec::new(), - infos: Vec::new() + infos: Vec::new(), + input_buf: Vec::new() } } } @@ -48,15 +63,34 @@ impl DisplayInfo { pub fn recv(&mut self, info: Infos) { self.infos.push(info); } + + pub fn ui_height(&self) -> u16 { + self.log_height + self.prompt_height + } +} + +/* + * wraps x so each line is indentation or fewer characters, after splitting by \n. + */ +fn into_display_lines(x: Vec, width: u16) -> Vec { + let split_on_newline: Vec = x.into_iter() + .flat_map(|x| x.split("\n") + .map(|x| x.to_owned()) + .collect::>() + ).collect(); + let wrapped: Vec = split_on_newline.iter() + .map(|x| x.chars().collect::>()) + .flat_map(|x| x.chunks(width as usize) + .map(|x| x.into_iter().collect::()) + .collect::>()) + .collect(); + wrapped } pub fn paint(tweeter: &mut ::tw::TwitterCache) -> Result<(), std::io::Error> { match termion::terminal_size() { - Ok((_width, height)) => { + Ok((width, height)) => { // draw input prompt - print!("{}{}", cursor::Goto(1, height - 6), clear::CurrentLine); - print!("{}{}>", cursor::Goto(1, height - 5), clear::CurrentLine); - print!("{}{}", cursor::Goto(1, height - 4), clear::CurrentLine); let mut i = 0; let log_size = 4; let last_elem = tweeter.display_info.log.len().saturating_sub(log_size); @@ -76,18 +110,68 @@ pub fn paint(tweeter: &mut ::tw::TwitterCache) -> Result<(), std::io::Error> { let last_twevent = tweeter.display_info.infos.len().saturating_sub(height as usize - 4).saturating_sub(tweeter.display_info.infos_seek as usize); let last_few_twevent: Vec = tweeter.display_info.infos[last_twevent..].iter().map(|x| x.clone()).rev().collect::>(); - let mut h = 7; + let mut h = tweeter.display_info.ui_height(); + + /* + * draw in whatever based on mode... + */ + match tweeter.display_info.mode.clone() { + None => { + print!("{}{}", cursor::Goto(1, height - 6), clear::CurrentLine); + print!("{}{}@{}>{}", cursor::Goto(1, height - 5), clear::CurrentLine, tweeter.current_user.handle, tweeter.display_info.input_buf.clone().into_iter().collect::()); + print!("{}{}", cursor::Goto(1, height - 4), clear::CurrentLine); + } + Some(DisplayMode::Compose(x)) => { + let mut lines: Vec = into_display_lines(x.split("\n").map(|x| x.to_owned()).collect(), width - 2); + if lines.len() == 0 { + lines.push("".to_owned()); + } + // TODO: properly probe tweet length lim + lines.push(format!("{}/{}", x.len(), 140)); + lines.insert(0, "".to_owned()); + let mut lines_drawn: u16 = 0; + for line in lines.into_iter().rev() { + print!("{}{} {}{}{}{}", + cursor::Goto(1, height - 4 - lines_drawn), clear::CurrentLine, + color::Bg(color::Blue), line, std::iter::repeat(" ").take((width as usize).saturating_sub(line.len() + 2)).collect::(), termion::style::Reset + ); + lines_drawn += 1; + } + h += (lines_drawn - 3); + } + Some(DisplayMode::Reply(twid, msg)) => { + let mut lines = into_display_lines(render_twete(&twid, tweeter), width - 2); + lines.push(" -------- ".to_owned()); + lines.extend(into_display_lines(msg.split("\n").map(|x| x.to_owned()).collect(), width - 2)); + if lines.len() == 0 { + lines.push("".to_owned()); + } + // TODO: properly probe tweet length lim + lines.push(format!("{}/{}", msg.len(), 140)); + lines.insert(0, "".to_owned()); + let mut lines_drawn: u16 = 0; + for line in lines.into_iter().rev() { + print!("{}{} {}{}{}{}", + cursor::Goto(1, height - 4 - lines_drawn), clear::CurrentLine, + color::Bg(color::Blue), line, std::iter::repeat(" ").take((width as usize).saturating_sub(line.len() + 2)).collect::(), termion::style::Reset + ); + lines_drawn += 1; + } + h += (lines_drawn - 3); + } + Some(_) => { } + } + for info in last_few_twevent { let to_draw: Vec = match info { Infos::Tweet(id) => { let pre_split: Vec = render_twete(&id, tweeter); - let split_on_newline: Vec = pre_split.into_iter().flat_map(|x| x.split("\n").map(|x| x.to_owned()).collect::>()).collect(); - let wrapped: Vec = split_on_newline.iter() - .map(|x| x.chars().collect::>()) - .flat_map(|x| x.chunks(_width as usize) - .map(|x| x.into_iter().collect::()) - .collect::>()) - .collect(); + let total_length: usize = pre_split.iter().map(|x| x.len()).sum(); + let wrapped = if total_length <= 1024 { + into_display_lines(pre_split, width) + } else { + vec!["This tweet discarded for your convenience".to_owned()] + }; wrapped.into_iter().rev().collect() } Infos::TweetWithContext(id, context) => { @@ -95,14 +179,34 @@ pub fn paint(tweeter: &mut ::tw::TwitterCache) -> Result<(), std::io::Error> { lines.push(context); lines } - Infos::Thread(_ids) => { - let mut lines = vec![format!("{}{}I'd show a thread if I knew how", cursor::Goto(1, height - h), clear::CurrentLine)]; + Infos::Thread(ids) => { + let mut tweets: Vec> = ids.iter().rev().map(|x| into_display_lines(render_twete(x, tweeter), width)).collect(); + let last = tweets.pop(); + let mut lines = tweets.into_iter().fold(Vec::new(), |mut sum, lines| { + sum.extend(lines); + sum.extend(vec![ + " ^".to_owned(), + " |".to_owned() + ]); + sum + }); + if let Some(last_lines) = last { + lines.extend(last_lines); + } + //let mut lines = vec![format!("{}{}I'd show a thread if I knew how", cursor::Goto(1, height - h), clear::CurrentLine)]; lines.push("".to_owned()); // lines.push(format!("link: https://twitter.com/i/web/status/{}", id)); - lines + lines.into_iter().rev().collect() }, Infos::Event(e) => { - e.clone().render(tweeter).into_iter().rev().collect() + let pre_split = e.clone().render(tweeter); + let total_length: usize = pre_split.iter().map(|x| x.len()).sum(); + let wrapped = if total_length <= 1024 { + into_display_lines(pre_split, width) + } else { + vec!["This tweet discarded for your convenience".to_owned()] + }; + wrapped.into_iter().rev().collect() }, Infos::DM(msg) => { vec![format!("{}{}DM: {}", cursor::Goto(1, height - h), clear::CurrentLine, msg)] @@ -132,7 +236,7 @@ pub fn paint(tweeter: &mut ::tw::TwitterCache) -> Result<(), std::io::Error> { print!("{}{}", cursor::Goto(1, height - h), clear::CurrentLine); h = h + 1; } - print!("{}", cursor::Goto(2, height - 5)); + print!("{}", cursor::Goto(2 + 1 + tweeter.current_user.handle.len() as u16 + tweeter.display_info.input_buf.len() as u16, height - 5)); stdout().flush()?; }, Err(e) => { diff --git a/src/main.rs b/src/main.rs index c46fec1..f8276ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,17 @@ #![feature(vec_remove_item)] extern crate serde_json; +extern crate termion; +extern crate termios; + +use termios::{Termios, TCSANOW, ECHO, ICANON, tcsetattr}; + +use termion::input::TermRead; +use termion::event::{Event, Key}; + use std::str; //use std::io::BufRead; +use std::io::stdin; #[macro_use] extern crate chan; @@ -174,15 +183,13 @@ fn main() { // let url = "https://stream.twitter.com/1.1/statuses/filter.json"; // let url = "https://stream.twitter.com/1.1/statuses/sample.json"; - let (ui_tx, mut ui_rx) = chan::sync::>(0); + let (ui_tx, mut ui_rx) = chan::sync::>(0); let mut twete_rx = connect_twitter_stream(); std::thread::spawn(move || { - loop { - let mut line = String::new(); - std::io::stdin().read_line(&mut line).unwrap(); - ui_tx.send(line.into_bytes()); + for input in stdin().events() { + ui_tx.send(input); } }); @@ -211,6 +218,13 @@ fn main() { core: c2 }; + let termios = Termios::from_fd(0).unwrap(); + let mut new_termios = termios.clone(); + + // fix terminal to not echo, thanks + new_termios.c_lflag &= !(ICANON | ECHO); + + tcsetattr(0, TCSANOW, &new_termios).unwrap(); loop { match do_ui(ui_rx, twete_rx, &mut tweeter, &mut queryer) { Some((new_ui_rx, new_twete_rx)) => { @@ -222,9 +236,82 @@ fn main() { } } } + tcsetattr(0, TCSANOW, &termios); } -fn do_ui(ui_rx_orig: chan::Receiver>, twete_rx: chan::Receiver>, mut tweeter: &mut tw::TwitterCache, mut queryer: &mut ::Queryer) -> Option<(chan::Receiver>, chan::Receiver>)> { +fn handle_input(event: termion::event::Event, tweeter: &mut tw::TwitterCache, queryer: &mut ::Queryer) { + match event { + Event::Key(Key::Backspace) => { + match tweeter.display_info.mode.clone() { + None => { tweeter.display_info.input_buf.pop(); }, + Some(display::DisplayMode::Compose(msg)) => { + let mut newstr = msg.clone(); + newstr.pop(); + tweeter.display_info.mode = Some(display::DisplayMode::Compose(newstr)); + }, + Some(display::DisplayMode::Reply(twid, msg)) => { + let mut newstr = msg.clone(); + newstr.pop(); + tweeter.display_info.mode = Some(display::DisplayMode::Reply(twid, newstr)); + } + } + } + // would Shift('\n') but.. that doesn't exist. + // would Ctrl('\n') but.. that doesn't work. + Event::Key(Key::Ctrl('n')) => { + match tweeter.display_info.mode.clone() { + Some(display::DisplayMode::Compose(msg)) => { + tweeter.display_info.mode = Some(display::DisplayMode::Compose(format!("{}{}", msg, "\n"))); + } + _ => {} + } + } + Event::Key(Key::Char(x)) => { + match tweeter.display_info.mode.clone() { + None => { + if x == '\n' { + let line = tweeter.display_info.input_buf.drain(..).collect::(); + tweeter.handle_user_input(line.into_bytes(), queryer); + } else { + tweeter.display_info.input_buf.push(x); + } + } + Some(display::DisplayMode::Compose(msg)) => { + if x == '\n' { + // TODO: move this somewhere better. + ::commands::twete::send_twete(msg, tweeter, queryer); + tweeter.display_info.mode = None; + } else { + tweeter.display_info.mode = Some(display::DisplayMode::Compose(format!("{}{}", msg, x))); + } + } + Some(display::DisplayMode::Reply(twid, msg)) => { + if x == '\n' { + // TODO: move this somewhere better. + ::commands::twete::send_reply(msg, twid, tweeter, queryer); + tweeter.display_info.mode = None; + } else { + tweeter.display_info.mode = Some(display::DisplayMode::Reply(twid, format!("{}{}", msg, x))); + } + } + } + }, + Event::Key(Key::PageUp) => { + tweeter.display_info.infos_seek += 1; + } + Event::Key(Key::PageDown) => { + tweeter.display_info.infos_seek = tweeter.display_info.infos_seek.saturating_sub(1); + } + Event::Key(Key::Esc) => { + tweeter.display_info.mode = None; + } + Event::Key(_) => { } + Event::Mouse(_) => { } + Event::Unsupported(_) => {} + } +} + +fn do_ui(ui_rx_orig: chan::Receiver>, twete_rx: chan::Receiver>, mut tweeter: &mut tw::TwitterCache, mut queryer: &mut ::Queryer) -> Option<(chan::Receiver>, chan::Receiver>)> { loop { let ui_rx_a = &ui_rx_orig; let ui_rx_b = &ui_rx_orig; @@ -243,27 +330,38 @@ fn do_ui(ui_rx_orig: chan::Receiver>, twete_rx: chan::Receiver>, tweeter.display_info.status("Twitter stream hung up...".to_owned()); chan_select! { ui_rx_b.recv() -> input => match input { - Some(line) => { - if line == "reconnect\n".as_bytes() { - return Some((ui_rx_orig.clone(), connect_twitter_stream())); + Some(maybe_event) => { + if let Ok(event) = maybe_event { + handle_input(event, tweeter, queryer); } else { - tweeter.handle_user_input(line, &mut queryer); + // stdin closed? } } + // twitter stream closed, ui thread closed, uhh.. None => std::process::exit(0) } } } }, ui_rx_a.recv() -> user_input => match user_input { - Some(line) => { - tweeter.handle_user_input(line, &mut queryer); + Some(maybe_event) => { + if let Ok(event) = maybe_event { + handle_input(event, tweeter, queryer); // eventually DisplayInfo too, as a separate piece of data... + } else { + // dunno how we'd reach this... stdin closed? + } }, None => tweeter.display_info.status("UI thread hung up...".to_owned()) } // and then we can introduce a channel that just sends a message every 100 ms or so // that acts as a clock! } + + match tweeter.state { + tw::AppState::Reconnect => return Some((ui_rx_orig.clone(), connect_twitter_stream())), + _ => () + }; + // one day display_info should be distinct match display::paint(tweeter) { Ok(_) => (), diff --git a/src/tw/mod.rs b/src/tw/mod.rs index 82dfe10..b1bc9c2 100644 --- a/src/tw/mod.rs +++ b/src/tw/mod.rs @@ -22,6 +22,16 @@ use self::tweet::Tweet; pub mod user; use self::user::User; +pub enum AppState { + Reconnect, + Compose, + View +} + +impl Default for AppState { + fn default() -> AppState { AppState::View } +} + pub fn full_twete_text(twete: &serde_json::map::Map) -> String { if twete.contains_key("retweeted_status") { return full_twete_text(twete["retweeted_status"].as_object().unwrap()) @@ -103,7 +113,9 @@ pub struct TwitterCache { #[serde(skip)] id_conversions: IdConversions, #[serde(skip)] - pub display_info: display::DisplayInfo + pub display_info: display::DisplayInfo, + #[serde(skip)] + pub state: AppState } // Internally, a monotonically increasin i64 is always the id used. @@ -259,7 +271,8 @@ impl TwitterCache { current_user: User::default(), threads: HashMap::new(), id_conversions: IdConversions::default(), - display_info: display::DisplayInfo::default() + display_info: display::DisplayInfo::default(), + state: AppState::View } } -- cgit v1.1