chiark / gitweb /
Rewrite the login step as part of the TUI.
authorSimon Tatham <anakin@pobox.com>
Thu, 18 Jan 2024 09:01:30 +0000 (09:01 +0000)
committerSimon Tatham <anakin@pobox.com>
Fri, 19 Jan 2024 08:21:37 +0000 (08:21 +0000)
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.

src/activity_stack.rs
src/client.rs
src/editor.rs
src/lib.rs
src/login.rs
src/main.rs
src/text.rs
src/tui.rs

index f25e655fcc9f878e7c563b8b221900330674a492..f746c06271a5098281838c3f795ee1ed76c93836 100644 (file)
@@ -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;
index 79d753ec50aeb1503032504193108ba47e9a3e96..2b597cc911e87d092376a105a762b2751c1f9b48 100644 (file)
@@ -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(),
index ef4b4e36534eb5b075930d86de1b10e09b66f3c1..de9fe00e6331cacdce65f51dd880f3eb61b5e6c5 100644 (file)
@@ -877,6 +877,11 @@ impl<Data: EditableMenuLineData> EditableMenuLine<Data> {
     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<Data: EditableMenuLineData> MenuKeypressLineGeneral
index 19e8b1d8b0f5672ded96e4494953ee2960dd9cea..422ad0aa8369b0eeb0b3c23d4863a015b5ebf7dd 100644 (file)
@@ -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,
index f17514a5ca86ef880900e382b49d216d95fc87e8..1190d92b688bd54259af6f50a7885cae1f940183 100644 (file)
 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<File>,
-) -> 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("<None>"), auth.instance_domain.as_deref().unwrap_or("<None>"), 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<String>,
+    ml_login: MenuKeypressLine,
+    para_login_instructions: Paragraph,
+    para_login_url: Paragraph,
+    el_logincode: EditableMenuLine<String>,
+    para_login_outcome: Paragraph,
+    state: LoginState,
+    application: Option<Application>,
+}
+
+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<String, ClientError> {
+        // 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<ColouredString>, CursorPosition) {
+        let push_split_lines =
+            |lines: &mut Vec<ColouredString>, output: Vec<ColouredString>| {
+                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<dyn ActivityState> {
+    Box::new(LoginMenu::new(cfgloc))
 }
index 383cb30d2d8fed4a499c50384ba7cd65d8389450..9403267a6e6415bf147dc2cda05c0fc7476259d7 100644 (file)
@@ -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<std::path::PathBuf>,
-
-    /// 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<String>,
 }
 
 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 {
index 95733a96cf9a35956047447102436b3bd92281f1..e732a95f1e65d9fdb354472f9b107e640db69538 100644 (file)
@@ -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() {
index 0524f71ec25a73eecf161cd16e430a3e29937449..e7c7ede2b5b43c45d11185bc5816ac68ede0e871 100644 (file)
@@ -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<ClientError> for TuiError {
 impl From<AuthError> 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))
             }