From: Simon Tatham Date: Thu, 18 Jan 2024 09:01:30 +0000 (+0000) Subject: Rewrite the login step as part of the TUI. X-Git-Url: http://www.chiark.greenend.org.uk/ucgi/~ian/git?a=commitdiff_plain;h=38ac322053f8eb7d2fa9984517d03b50109186cf;p=mastodonochrome.git Rewrite the login step as part of the TUI. Now it's reasonably user-friendly, and once you've logged in, you can transition straight to actually using the client. Also, this menu is a good place to put a 'register as new user' alternative option. --- diff --git a/src/activity_stack.rs b/src/activity_stack.rs index f25e655..f746c06 100644 --- a/src/activity_stack.rs +++ b/src/activity_stack.rs @@ -11,6 +11,7 @@ pub enum NonUtilityActivity { UserPosts(String, Boosts, Replies), ComposeToplevel, PostComposeMenu, + LoginMenu, } #[derive(PartialEq, Eq, Debug, Clone)] @@ -129,6 +130,17 @@ impl ActivityStack { self.overlay = None; self.util = Some(x); } + Activity::NonUtil(NonUtilityActivity::MainMenu) => { + // Special case: going to the Main Menu replaces the + // topmost activity on the stack _even_ if it was not + // previously the Main Menu. (Because it might have + // been a preliminary login step and now we're + // transitioning into full client operation.) + self.util = None; + self.overlay = None; + self.nonutil.clear(); + self.nonutil.push(NonUtilityActivity::MainMenu); + } Activity::NonUtil(x) => { self.util = None; self.overlay = None; diff --git a/src/client.rs b/src/client.rs index 79d753e..2b597cc 100644 --- a/src/client.rs +++ b/src/client.rs @@ -480,6 +480,15 @@ impl Client { self.logfile = file; } + pub fn clear_caches(&mut self) { + self.accounts.clear(); + self.statuses.clear(); + self.notifications.clear(); + self.polls.clear(); + self.feeds.clear(); + self.instance = None; + } + pub fn fq(&self, acct: &str) -> String { match acct.contains('@') { true => acct.to_owned(), diff --git a/src/editor.rs b/src/editor.rs index ef4b4e3..de9fe00 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -877,6 +877,11 @@ impl EditableMenuLine { pub fn is_editing(&self) -> bool { self.editor.is_some() } + pub fn set_data(&mut self, data: Data) { + self.data = data; + self.menuline = + Self::make_menuline(self.key, &self.description, &self.data); + } } impl MenuKeypressLineGeneral diff --git a/src/lib.rs b/src/lib.rs index 19e8b1d..422ad0a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,15 +21,6 @@ pub struct TopLevelError { message: String, } -impl TopLevelError { - fn new(prefix: &str, message: &str) -> Self { - TopLevelError { - prefix: prefix.to_owned(), - message: message.to_owned(), - } - } -} - impl std::fmt::Display for TopLevelError { fn fmt( &self, diff --git a/src/login.rs b/src/login.rs index f17514a..1190d92 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,101 +1,489 @@ use reqwest::Url; -use std::fs::File; -use std::io::Write; -use super::auth::{AuthConfig, AuthError}; -use super::client::{Client, ClientError, AppTokenType}; +use super::auth::AuthConfig; +use super::client::{AppTokenType, Client, ClientError}; +use super::coloured_string::*; use super::config::ConfigLocation; +use super::editor::EditableMenuLine; +use super::text::*; +use super::tui::{ + ActivityState, CursorPosition, LogicalAction, OurKey, OurKey::*, +}; use super::types::{Account, Application, Instance}; use super::TopLevelError; -pub fn login( - cfgloc: &ConfigLocation, - instance_url: &str, - logfile: Option, -) -> Result<(), TopLevelError> { - // First, check we aren't logged in already, and give some - // marginally useful advice on what to do if we are. - match AuthConfig::load(cfgloc) { - Err(AuthError::Nonexistent(..)) => Ok(()), - Ok(auth) => Err(TopLevelError::new("", &format!( - "you are already logged in as {0}@{1}! Use --config to specify a separate configuration directory for another login, or delete {2} to remove the existing login details", - auth.username.as_deref().unwrap_or(""), auth.instance_domain.as_deref().unwrap_or(""), cfgloc.get_path("auth").display()))), - Err(e) => Err(TopLevelError::from(e)), - }?; - - // Ergonomics: parse the URL, adding a default https:// scheme if - // it's just a bare hostname - let urlstr = match instance_url.find('/') { - Some(_) => instance_url.to_owned(), - None => format!("https://{instance_url}"), - }; - let url = match Url::parse(&urlstr) { - Ok(url) => Ok(url), - Err(e) => { - Err(ClientError::UrlParseError(urlstr.clone(), e.to_string())) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LoginState { + Initial, + ServerValid, + LoginToken, + LoginTokenError, + LoginSuccess, + LoginFailure, +} + +struct LoginMenu { + cfgloc: ConfigLocation, + title: FileHeader, + alignment_line: MenuKeypressLine, + para_intro: Paragraph, + para_server_id: Paragraph, + el_server: EditableMenuLine, + ml_login: MenuKeypressLine, + para_login_instructions: Paragraph, + para_login_url: Paragraph, + el_logincode: EditableMenuLine, + para_login_outcome: Paragraph, + state: LoginState, + application: Option, +} + +impl LoginMenu { + fn new(cfgloc: ConfigLocation) -> Self { + let title = FileHeader::new(ColouredString::uniform( + &format!("Log in to a Mastodon server"), + 'H', + )); + + let alignment_line = MenuKeypressLine::new( + Pr('X'), + ColouredString::plain( + "Authorisation code: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + ), + ); + let para_intro = Paragraph::new().set_centred(true).set_indent(10, 10) + .add(ColouredString::general( + "To log in to a Mastodon server, enter the URL of its website. (Not always the same as the domain name that appears in its usernames.)", + "HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH=======HHHHHHH======HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH")); + let el_server = EditableMenuLine::new( + Pr('S'), + ColouredString::plain("Server URL: "), + "https://".to_owned(), + ); + let ml_login = MenuKeypressLine::new( + Pr('L'), + ColouredString::uniform( + "Log in to this server as an existing user", + 'H', + ), + ); + + let para_server_id = + Paragraph::new().set_centred(true).set_indent(10, 10); + let para_login_instructions = + Paragraph::new().set_centred(true).set_indent(10, 10); + let para_login_url = Paragraph::new(); + + let el_logincode = EditableMenuLine::new( + Pr('C'), + ColouredString::plain("Authorisation code: "), + "".to_owned(), + ); + + let para_login_outcome = + Paragraph::new().set_centred(true).set_indent(10, 10); + + let mut menu = LoginMenu { + cfgloc, + title, + alignment_line, + para_intro, + el_server, + ml_login, + para_server_id, + para_login_instructions, + para_login_url, + el_logincode, + para_login_outcome, + state: LoginState::Initial, + application: None, + }; + menu.fix_widths(); + menu.el_server.start_editing(); + menu + } + + fn fix_widths(&mut self) -> (usize, usize) { + let mut lmaxwid = 0; + let mut rmaxwid = 0; + self.alignment_line.check_widths(&mut lmaxwid, &mut rmaxwid); + self.el_server.check_widths(&mut lmaxwid, &mut rmaxwid); + self.ml_login.check_widths(&mut lmaxwid, &mut rmaxwid); + self.el_logincode.check_widths(&mut lmaxwid, &mut rmaxwid); + + self.el_server.reset_widths(); + self.ml_login.reset_widths(); + self.el_logincode.reset_widths(); + + self.el_server.ensure_widths(lmaxwid, rmaxwid); + self.ml_login.ensure_widths(lmaxwid, rmaxwid); + self.el_logincode.ensure_widths(lmaxwid, rmaxwid); + + (lmaxwid, rmaxwid) + } + + fn verify_server(&mut self, client: &mut Client) -> LogicalAction { + self.para_server_id.clear(); + + let instance_url = self.el_server.get_data(); + let urlstr = match instance_url.find('/') { + Some(_) => instance_url.to_owned(), + None => format!("https://{instance_url}"), + }; + let url = match Url::parse(&urlstr) { + Ok(url) => url, + Err(e) => { + self.para_server_id.push_text( + ColouredString::uniform( + &format!("Error parsing the URL: {e}"), + 'r', + ), + false, + ); + self.state = LoginState::Initial; + return LogicalAction::Beep; + } + }; + let instance_url = url.as_str().trim_end_matches('/'); + self.el_server.set_data(instance_url.to_owned()); + client.auth = AuthConfig::default(); + client.auth.instance_url = Some(instance_url.to_owned()); + + client.clear_caches(); + let inst_fallible = client.instance(); + client.clear_caches(); + self.application = None; + + let action = match inst_fallible { + Ok(instance) => { + self.para_server_id.push_text( + ColouredString::uniform( + "OK! This is the Mastodon instance ", + 'H', + ), + false, + ); + self.para_server_id.push_text( + ColouredString::uniform(&instance.domain, 'K'), + false, + ); + self.para_server_id + .push_text(ColouredString::uniform(".", 'H'), false); + self.state = LoginState::ServerValid; + LogicalAction::Nothing + } + Err(e) => { + self.para_server_id.push_text( + ColouredString::uniform( + &format!("Error confirming that instance: {e}"), + 'r', + ), + false, + ); + client.auth = AuthConfig::default(); + self.state = LoginState::Initial; + LogicalAction::Beep + } + }; + + self.fix_widths(); + action + } + + fn get_login_url_fallible( + &mut self, + client: &mut Client, + ) -> Result { + // Register the client and get its details + let app = client.register_client()?; + let app_token = + client.get_app_token(&app, AppTokenType::ClientCredentials)?; + client.auth.user_token = Some(app_token.access_token); + let _app: Application = client.verify_app_credentials()?; + + // Get the URL the user will have to visit + let url = client.get_auth_url(&app)?; + + self.application = Some(app); + + Ok(url) + } + + fn get_login_url(&mut self, client: &mut Client) -> LogicalAction { + match self.get_login_url_fallible(client) { + Ok(url) => { + self.para_login_instructions.clear(); + self.para_login_instructions.push_text( + ColouredString::uniform( + "Log in to your user account on the instance website ", + 'H', + ), + false, + ); + self.para_login_instructions.push_text( + ColouredString::uniform( + client.auth.instance_url.as_deref().expect( + "In this login state we should have a URL!", + ), + 'u', + ), + false, + ); + self.para_login_instructions.push_text( + ColouredString::uniform( + " and then visit this URL to get an authorisation code for Mastodonochrome:", + 'H', + ), + false, + ); + self.para_login_url.clear(); + self.para_login_url + .push_text(ColouredString::uniform(&url, 'u'), false); + self.state = LoginState::LoginToken; + self.el_logincode.start_editing(); + LogicalAction::Nothing + } + Err(e) => { + self.para_login_instructions.clear(); + self.para_login_instructions.push_text( + ColouredString::uniform( + &format!("Error setting up the login: {e}"), + 'r', + ), + false, + ); + self.para_login_url.clear(); + self.state = LoginState::LoginTokenError; + LogicalAction::Beep + } } - }?; - let instance_url = url.as_str().trim_end_matches('/'); - - let mut client = Client::new(AuthConfig::default())?; - client.set_logfile(logfile); - client.set_writable(true); - client.auth.instance_url = Some(instance_url.to_owned()); - - // Register the client and get its details - let app = client.register_client()?; - let app_token = client.get_app_token(&app, AppTokenType::ClientCredentials)?; - client.auth.user_token = Some(app_token.access_token); - let _app: Application = client.verify_app_credentials()?; - - // Get the URL the user will have to visit - let url = client.get_auth_url(&app)?; - - // Print it - println!("Log in to the website {instance_url}/"); - println!("and then visit this URL to authorise Mastodonochrome:"); - println!(); - println!("{}", url); - println!(); - println!("If you click \"Authorise\" on that page, you should receive a response code."); - print!("Enter that code here: "); - std::io::stdout().flush().unwrap(); // FIXME - - // Read the user's response - let code = { - let mut buf = String::new(); - std::io::stdin().read_line(&mut buf).unwrap(); // FIXME - buf - }; - let code = code.trim_end(); - - // Use that code to get the final user access token - let token = client.get_app_token(&app, AppTokenType::AuthorizationCode(code))?; - client.auth.user_token = Some(token.access_token.clone()); - let account: Account = client.verify_account_credentials()?; - let instance: Instance = client.instance()?; - - println!(); - println!( - "Successfully logged in as {}@{}", - account.username, instance.domain - ); - println!("Now run 'mastodonochrome' without arguments to read and post!"); - - // Save everything! - let auth = AuthConfig { - account_id: Some(account.id), - username: Some(account.username), - instance_url: Some(instance_url.to_owned()), - instance_domain: Some(instance.domain), - client_id: Some(app.client_id.unwrap()), - client_secret: Some(app.client_secret.unwrap()), - user_token: Some(token.access_token), - }; - - let mut json = serde_json::to_string_pretty(&auth).unwrap(); - json.push('\n'); - cfgloc.create_file("auth", &json)?; - - Ok(()) + } + + fn verify_login_code_fallible( + &mut self, + client: &mut Client, + ) -> Result<(), TopLevelError> { + let code = self.el_logincode.get_data().trim(); + let app = self + .application + .as_ref() + .expect("Can't get here without registering an application"); + + // Use our code to get the final user access token and all the + // account details + let token = client + .get_app_token(app, AppTokenType::AuthorizationCode(code))?; + client.auth.user_token = Some(token.access_token.clone()); + let account: Account = client.verify_account_credentials()?; + let instance: Instance = client.instance()?; + + client.auth = AuthConfig { + account_id: Some(account.id), + username: Some(account.username), + instance_url: client.auth.instance_url.clone(), + instance_domain: Some(instance.domain), + client_id: Some(app.client_id.clone().unwrap()), + client_secret: Some(app.client_secret.clone().unwrap()), + user_token: Some(token.access_token), + }; + + let mut json = serde_json::to_string_pretty(&client.auth).unwrap(); + json.push('\n'); + self.cfgloc.create_file("auth", &json)?; + + Ok(()) + } + + fn verify_login_code(&mut self, client: &mut Client) -> LogicalAction { + match self.verify_login_code_fallible(client) { + Ok(_) => { + self.para_login_outcome.clear(); + self.para_login_outcome.push_text( + ColouredString::uniform("Success! Logged in as ", 'H'), + false, + ); + self.para_login_outcome.push_text( + ColouredString::uniform(&client.our_account_fq(), 'K'), + false, + ); + self.para_login_outcome.push_text( + ColouredString::general( + ". Now press [SPACE] to continue to the Main Menu.", + "HHHHHHHHHHHHHKKKKKHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH", + ), + false, + ); + self.state = LoginState::LoginSuccess; + LogicalAction::Nothing + } + Err(e) => { + self.para_login_outcome.clear(); + self.para_login_outcome.push_text( + ColouredString::uniform( + &format!("Error logging in: {e}"), + 'r', + ), + false, + ); + self.state = LoginState::LoginFailure; + LogicalAction::Beep + } + } + } +} + +impl ActivityState for LoginMenu { + fn draw( + &self, + w: usize, + h: usize, + ) -> (Vec, CursorPosition) { + let push_split_lines = + |lines: &mut Vec, output: Vec| { + for line in output { + for frag in line.split(w.saturating_sub(1)) { + lines.push(frag.into()); + } + } + }; + 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()); + push_split_lines(&mut lines, self.para_intro.render(w)); + lines.extend_from_slice(&BlankLine::render_static()); + lines.push(self.el_server.render(w, &mut cursorpos, lines.len())); + push_split_lines(&mut lines, self.para_server_id.render(w)); + if self.state == LoginState::ServerValid { + lines.extend_from_slice(&BlankLine::render_static()); + lines.extend_from_slice(&self.ml_login.render(w)); + } + if self.state == LoginState::LoginToken + || self.state == LoginState::LoginTokenError + || self.state == LoginState::LoginSuccess + || self.state == LoginState::LoginFailure + { + lines.extend_from_slice(&BlankLine::render_static()); + push_split_lines( + &mut lines, + self.para_login_instructions.render(w), + ); + } + if self.state == LoginState::LoginToken + || self.state == LoginState::LoginSuccess + || self.state == LoginState::LoginFailure + { + lines.extend_from_slice(&BlankLine::render_static()); + push_split_lines(&mut lines, self.para_login_url.render(w)); + lines.extend_from_slice(&BlankLine::render_static()); + lines.push(self.el_logincode.render( + w, + &mut cursorpos, + lines.len(), + )); + } + if self.state == LoginState::LoginSuccess + || self.state == LoginState::LoginFailure + { + lines.extend_from_slice(&BlankLine::render_static()); + push_split_lines(&mut lines, self.para_login_outcome.render(w)); + } + + while lines.len() + 1 < h { + lines.extend_from_slice(&BlankLine::render_static()); + } + + let status = FileStatusLine::new(); + + let status = match self.state { + LoginState::Initial => { + if self.el_server.is_editing() { + status.message("Enter server URL and press Return") + } else { + status.add(Pr('Q'), "Quit", 100) + } + } + LoginState::LoginToken => { + if self.el_server.is_editing() { + status.message("Enter code from server and press Return") + } else { + status.add(Pr('Q'), "Quit", 100) + } + } + LoginState::LoginSuccess => status.add(Space, "Main Menu", 100), + _ => status.add(Pr('Q'), "Quit", 100), + } + .finalise(); + lines.extend_from_slice(&status.render(w)); + + (lines, cursorpos) + } + + fn handle_keypress( + &mut self, + key: OurKey, + client: &mut Client, + ) -> LogicalAction { + // Let editable menu lines have first crack at the keypress + if self.el_server.handle_keypress(key) { + self.fix_widths(); + if !self.el_server.is_editing() { + return self.verify_server(client); + } + return LogicalAction::Nothing; + } else if self.el_logincode.handle_keypress(key) { + self.fix_widths(); + if !self.el_logincode.is_editing() { + return self.verify_login_code(client); + } + return LogicalAction::Nothing; + } + + match key { + Pr('s') | Pr('S') => match self.state { + LoginState::Initial | LoginState::ServerValid => { + self.state = LoginState::Initial; + self.el_server.start_editing() + } + _ => LogicalAction::Nothing, + }, + Pr('l') | Pr('L') => { + if self.state == LoginState::ServerValid { + self.get_login_url(client) + } else { + LogicalAction::Nothing + } + } + Pr('c') | Pr('C') => { + if self.state == LoginState::LoginToken + || self.state == LoginState::LoginFailure + { + self.el_logincode.start_editing() + } else { + LogicalAction::Nothing + } + } + Space => { + if self.state == LoginState::LoginSuccess { + LogicalAction::FinishedLoggingIn + } else { + LogicalAction::Nothing + } + } + + // In this initial screen we don't require ESC X X to get out + Pr('q') | Pr('Q') => LogicalAction::Exit, + + _ => LogicalAction::Nothing, + } + } + + fn resize(&mut self, w: usize, _h: usize) { + self.el_server.resize(w); + self.el_logincode.resize(w); + } +} + +pub fn login_menu(cfgloc: ConfigLocation) -> Box { + Box::new(LoginMenu::new(cfgloc)) } diff --git a/src/main.rs b/src/main.rs index 383cb30..9403267 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ use clap::Parser; use std::process::ExitCode; use mastodonochrome::config::ConfigLocation; -use mastodonochrome::login::login; use mastodonochrome::tui::Tui; use mastodonochrome::TopLevelError; @@ -19,11 +18,6 @@ struct Args { /// HTTP logging mode: the client logs all its transactions to a file. #[arg(long)] loghttp: Option, - - /// Log in to a server, instead of running the main user interface. - /// Provide the top-level URL of the instance website. - #[arg(long, conflicts_with("readonly"))] - login: Option, } fn main_inner() -> Result<(), TopLevelError> { @@ -36,11 +30,7 @@ fn main_inner() -> Result<(), TopLevelError> { None => None, Some(path) => Some(std::fs::File::create(path)?), }; - match cli.login { - None => Tui::run(&cfgloc, cli.readonly, httplogfile)?, - Some(ref server) => login(&cfgloc, server, httplogfile)?, - } - Ok(()) + Ok(Tui::run(&cfgloc, cli.readonly, httplogfile)?) } fn main() -> ExitCode { diff --git a/src/text.rs b/src/text.rs index 95733a9..e732a95 100644 --- a/src/text.rs +++ b/src/text.rs @@ -553,6 +553,10 @@ impl Paragraph { self } + pub fn clear(&mut self) { + self.words.clear(); + } + pub fn end_word(&mut self) { if let Some(word) = self.words.last() { if !word.is_space() { diff --git a/src/tui.rs b/src/tui.rs index 0524f71..e7c7ede 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -28,6 +28,7 @@ use super::coloured_string::*; use super::config::ConfigLocation; use super::editor::*; use super::file::*; +use super::login::login_menu; use super::menu::*; use super::options::*; use super::posting::*; @@ -94,7 +95,8 @@ fn ratatui_style_from_colour(colour: char) -> Style { // ~~~~~ underline in file headers '~' => Style::default().fg(Color::Blue), - // actual header text in file headers + // actual header text in file headers and other client-originated + // instructions or information 'H' => Style::default().fg(Color::Cyan), // keypress / keypath names in file headers @@ -102,6 +104,11 @@ fn ratatui_style_from_colour(colour: char) -> Style { .fg(Color::Cyan) .add_modifier(Modifier::BOLD), + // underlined text for emphasis in 'H' text + '=' => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED), + // keypresses in file status lines 'k' => Style::default().add_modifier(Modifier::BOLD), @@ -166,6 +173,7 @@ pub enum PhysicalAction { Beep, Refresh, Exit, + MainSessionSetup, Error(TuiError), } @@ -230,7 +238,7 @@ impl From for TuiError { impl From for TuiError { fn from(err: AuthError) -> Self { TuiError { - message: format!("unable to read authentication: {}", err) + message: format!("unable to read authentication: {}", err), } } } @@ -277,7 +285,16 @@ impl Tui { } }); - let auth = AuthConfig::load(cfgloc)?; + let auth = match AuthConfig::load(cfgloc) { + // If we don't have any authentication at all, that's OK, + // and we put the TUI into login mode + Err(AuthError::Nonexistent(..)) => Ok(AuthConfig::default()), + + // Pass through any other Ok or Err, the latter to be + // handled by the ? + x => x, + }?; + let mut client = Client::new(auth)?; client.set_writable(!readonly); client.set_logfile(logfile); @@ -358,7 +375,7 @@ impl Tui { } } - fn run_inner(&mut self) -> Result<(), TuiError> { + fn main_session_setup(&mut self) -> Result<(), TuiError> { self.start_streaming_subthread(StreamId::User)?; self.start_timing_subthread( Duration::from_secs(120), @@ -382,6 +399,14 @@ impl Tui { Self::beep()?; } + Ok(()) + } + + fn run_inner(&mut self) -> Result<(), TuiError> { + if self.client.auth.is_logged_in() { + self.main_session_setup()?; + } + self.main_loop() } @@ -427,17 +452,19 @@ impl Tui { fn main_loop(&mut self) -> Result<(), TuiError> { 'outer: loop { - let state = &mut self.state; - - self.terminal.draw(|frame| { - let area = frame.size(); - let buf = frame.buffer_mut(); - if let Some((x, y)) = state.draw_frame(area, buf) { - if let (Ok(x), Ok(y)) = (x.try_into(), y.try_into()) { - frame.set_cursor(x, y); + { + let state = &mut self.state; + + self.terminal.draw(|frame| { + let area = frame.size(); + let buf = frame.buffer_mut(); + if let Some((x, y)) = state.draw_frame(area, buf) { + if let (Ok(x), Ok(y)) = (x.try_into(), y.try_into()) { + frame.set_cursor(x, y); + } } - } - })?; + })?; + } // One physical keypress can break down into multiple // things we treat as logical keypresses. So we must do an @@ -460,7 +487,7 @@ impl Tui { Ok(SubthreadEvent::TermEv(ev)) => match ev { Event::Key(key) => { if key.kind == KeyEventKind::Press { - state.new_event(); + self.state.new_event(); Self::translate_keypress(key) .into_iter() .map(|key| Todo::Keypress(key)) @@ -482,7 +509,7 @@ impl Tui { } } Ok(SubthreadEvent::LDBCheckpointTimer) => { - state.checkpoint_ldb(); + self.state.checkpoint_ldb(); Vec::new() } }; @@ -490,9 +517,9 @@ impl Tui { for todo in todos { let physact = match todo { Todo::Keypress(ourkey) => { - state.handle_keypress(ourkey, &mut self.client) + self.state.handle_keypress(ourkey, &mut self.client) } - Todo::Stream(feeds_updated) => state + Todo::Stream(feeds_updated) => self.state .handle_feed_updates(feeds_updated, &mut self.client), }; @@ -501,6 +528,9 @@ impl Tui { PhysicalAction::Exit => break 'outer Ok(()), PhysicalAction::Refresh => self.terminal.clear()?, PhysicalAction::Error(err) => break 'outer Err(err), + PhysicalAction::MainSessionSetup => { + self.main_session_setup()? + } PhysicalAction::Nothing => (), } } @@ -528,6 +558,7 @@ pub enum LogicalAction { PopOverlayBeep, GotSearchExpression(SearchDirection, String), Goto(Activity), + FinishedLoggingIn, Exit, Nothing, Error(ClientError), // throw UI into the Error Log @@ -597,8 +628,12 @@ struct TuiLogicalState { impl TuiLogicalState { fn new(client: &Client, cfgloc: ConfigLocation) -> Self { - let activity_stack = ActivityStack::new(NonUtilityActivity::MainMenu); - let activity_state = main_menu(client); + let (activity, activity_state) = if client.auth.is_logged_in() { + (NonUtilityActivity::MainMenu, main_menu(client)) + } else { + (NonUtilityActivity::LoginMenu, login_menu(cfgloc.clone())) + }; + let activity_stack = ActivityStack::new(activity); TuiLogicalState { activity_stack, @@ -712,6 +747,12 @@ impl TuiLogicalState { self.changed_activity(client, None, false); break PhysicalAction::Nothing; } + LogicalAction::FinishedLoggingIn => { + self.activity_stack + .goto(NonUtilityActivity::MainMenu.into()); + self.changed_activity(client, None, false); + break PhysicalAction::MainSessionSetup; + } LogicalAction::Pop => { self.activity_stack.pop(); self.changed_activity(client, None, false); @@ -874,6 +915,9 @@ impl TuiLogicalState { Activity::NonUtil(NonUtilityActivity::MainMenu) => { Ok(main_menu(client)) } + Activity::NonUtil(NonUtilityActivity::LoginMenu) => { + Ok(login_menu(self.cfgloc.clone())) + } Activity::Util(UtilityActivity::UtilsMenu) => { Ok(utils_menu(client)) }