Browse Source

feat(keys)!: use function keys for commands (F3/F4/F10)

Ctrl chords clash with GNU screen (prefix Ctrl+A) and legacy terminals
cannot tell Ctrl+Shift+A from Ctrl+A.

- app: F3 toggles AI for the open chat, F4 toggles AI for all chats,
  F10 quits on any screen; Ctrl chords are ignored so they never type text;
  removed QUIT_CHAR, AUTOREPLY_CHAR and is_global_toggle
- ui: footer shows F3/F4/F10 (F4 green when global AI is on)
- README: key table updated

BREAKING CHANGE: Ctrl+A, Ctrl+Shift+A and Ctrl+Q are replaced by F3, F4 and F10.
softword 2 days ago
parent
commit
8229b35a98
3 changed files with 44 additions and 57 deletions
  1. 6 4
      README.md
  2. 31 48
      src/app.rs
  3. 7 5
      src/ui.rs

+ 6 - 4
README.md

@@ -68,16 +68,18 @@ Settings live in `config.toml` in the data directory. Start from `config.toml.gl
 | `F2` | chats/contacts |
 | typing (list focused) | filter names; `Backspace` edits, `Esc` clears |
 | `Canc`/`Delete` twice | delete chat locally (history only, not on WhatsApp) |
-| `Ctrl+A` | AI autoreply for the open chat |
-| `Ctrl+Shift+A` | AI autoreply for all chats (persistent; footer shortcut turns green when on) |
-| `Ctrl+Q` | quit |
+| `F3` | AI autoreply for the open chat |
+| `F4` | AI autoreply for all chats (persistent; footer shortcut turns green when on) |
+| `F10` | quit |
+
+Only function keys are used for commands, so they work inside GNU screen, tmux and over SSH.
 
 ## Web control panel
 
 - Enabled by `web_port` in `config.toml` (e.g. `web_port = 8080`); open `http://<host>:<port>/`.
 - Listens on `0.0.0.0` without authentication: use it only on a trusted network (e.g. a VPN).
 - The page (HTML/CSS/JS) is embedded in the binary: no extra files to deploy.
-- Features: "AI tutte" switch (same as `Ctrl+Shift+A`), per-chat AI toggle grid (private chats and groups only), "Chiudi bot" button to quit. The page refreshes every 5 seconds; changes show up in the TUI too.
+- Features: "AI tutte" switch (same as `F4`), per-chat AI toggle grid (private chats and groups only), "Chiudi bot" button to quit. The page refreshes every 5 seconds; changes show up in the TUI too.
 - API (for scripts): `GET /api/state` (JSON `{global_autoreply, chats:[{jid,name,autoreply}], status}`); `POST /api/global`; `POST /api/chat` with the jid as plain-text body; `POST /api/quit`. Every POST requires the header `X-Wh-Request: 1` (otherwise 403), which blocks cross-site requests from other pages.
 
 Example:

+ 31 - 48
src/app.rs

@@ -21,11 +21,12 @@ pub const MESSAGES_SHOWN: usize = 200;
 
 /// Key code of the function key that toggles the list mode.
 const LIST_MODE_FUNCTION_KEY: u8 = 2;
-/// Character that, combined with Ctrl, quits the application.
-const QUIT_CHAR: char = 'q';
-/// Character that, combined with Ctrl, toggles the autoreply of the open chat; with Ctrl+Shift
-/// it toggles the global autoreply.
-const AUTOREPLY_CHAR: char = 'a';
+/// Key code of the function key (F3) that toggles the autoreply of the open chat.
+const AUTOREPLY_FUNCTION_KEY: u8 = 3;
+/// Key code of the function key (F4) that toggles the global autoreply.
+const GLOBAL_AUTOREPLY_FUNCTION_KEY: u8 = 4;
+/// Key code of the function key (F10) that quits the application.
+const QUIT_FUNCTION_KEY: u8 = 10;
 /// Sender jid stored for messages written by the user (own account).
 const OWN_SENDER_JID: &str = "";
 
@@ -565,25 +566,21 @@ impl App {
     /// Purpose: handle a key press on any screen.
     /// Inputs: `key`, the pressed key.
     /// Output: `Ok(())`, or a history error.
-    /// Side effects: may quit, change focus/mode/selection, edit the input, send or open chats.
+    /// Side effects: F10 quits on any screen; on the main screen F2 toggles the list mode, F3 the
+    /// autoreply of the open chat and F4 the global autoreply (any focus); Ctrl command chords are
+    /// ignored so they never type into the input or the filter; otherwise may change
+    /// focus/selection, edit the input, send or open chats.
     fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
-        let ctrl = is_command(key.modifiers);
-        if ctrl && key.code == KeyCode::Char(QUIT_CHAR) {
+        if key.code == KeyCode::F(QUIT_FUNCTION_KEY) {
             self.should_quit = true;
             return Ok(());
         }
         if self.screen != Screen::Main {
             return Ok(());
         }
-        if ctrl {
+        if is_command(key.modifiers) {
             self.cancel_pending_delete();
-            if is_global_toggle(&key) {
-                return self.toggle_global_autoreply();
-            }
-            return match key.code {
-                KeyCode::Char(AUTOREPLY_CHAR) => self.toggle_autoreply(),
-                _ => Ok(()),
-            };
+            return Ok(());
         }
         match (key.code, self.focus) {
             (KeyCode::Tab, _) => {
@@ -591,6 +588,14 @@ impl App {
                 self.focus = toggled_focus(self.focus);
             }
             (KeyCode::F(LIST_MODE_FUNCTION_KEY), _) => self.toggle_list_mode(),
+            (KeyCode::F(AUTOREPLY_FUNCTION_KEY), _) => {
+                self.cancel_pending_delete();
+                return self.toggle_autoreply();
+            }
+            (KeyCode::F(GLOBAL_AUTOREPLY_FUNCTION_KEY), _) => {
+                self.cancel_pending_delete();
+                return self.toggle_global_autoreply();
+            }
             (code, Focus::List) => return self.handle_list_key(code),
             (code, Focus::Input) => self.handle_input_key(code),
         }
@@ -755,7 +760,7 @@ impl App {
         self.selected = 0;
     }
 
-    /// Purpose: toggle the AI autoreply of the open chat.
+    /// Purpose: toggle the AI autoreply of the open chat (F3).
     /// Inputs: none.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: writes the history, updates `status`, reloads chats; refuses to enable
@@ -831,7 +836,7 @@ impl App {
         }
     }
 
-    /// Purpose: toggle the global AI autoreply, which answers every chat regardless of the
+    /// Purpose: toggle the global AI autoreply (F4), which answers every chat regardless of the
     /// per-chat flags.
     /// Inputs: none.
     /// Output: `Ok(())`, or a history error.
@@ -1159,20 +1164,6 @@ fn is_command(modifiers: KeyModifiers) -> bool {
     modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT)
 }
 
-/// Purpose: tell whether a key event is the Shift variant of the autoreply chord.
-/// Inputs: `key`, the key event (callers check the Ctrl command chord separately).
-/// Output: `true` when the key is `AUTOREPLY_CHAR` (either case) with Shift held; `false` otherwise.
-/// Side effects: none. Terminals may report Ctrl+Shift+A as `'a'` or `'A'` with Shift, so the
-/// character is compared ignoring ASCII case.
-fn is_global_toggle(key: &KeyEvent) -> bool {
-    match key.code {
-        KeyCode::Char(c) => {
-            c.eq_ignore_ascii_case(&AUTOREPLY_CHAR) && key.modifiers.contains(KeyModifiers::SHIFT)
-        }
-        _ => false,
-    }
-}
-
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1292,21 +1283,13 @@ mod tests {
     }
 
     #[test]
-    fn is_global_toggle_accepts_ctrl_shift_a_in_both_cases() {
-        let chord = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
-        assert!(is_global_toggle(&KeyEvent::new(KeyCode::Char('a'), chord)));
-        assert!(is_global_toggle(&KeyEvent::new(KeyCode::Char('A'), chord)));
-    }
-
-    #[test]
-    fn is_global_toggle_rejects_ctrl_only_and_other_chars() {
-        let chord = KeyModifiers::CONTROL | KeyModifiers::SHIFT;
-        assert!(!is_global_toggle(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)));
-        assert!(!is_global_toggle(&KeyEvent::new(KeyCode::Char('c'), chord)));
-    }
-
-    #[test]
-    fn ctrl_q_is_the_quit_key() {
-        assert_eq!(QUIT_CHAR, 'q');
+    fn command_function_keys_are_fixed_and_distinct() {
+        let keys = [
+            LIST_MODE_FUNCTION_KEY,
+            AUTOREPLY_FUNCTION_KEY,
+            GLOBAL_AUTOREPLY_FUNCTION_KEY,
+            QUIT_FUNCTION_KEY,
+        ];
+        assert_eq!(keys, [2, 3, 4, 10]);
     }
 }

+ 7 - 5
src/ui.rs

@@ -52,11 +52,11 @@ const HIGHLIGHT_SYMBOL: &str = "> ";
 const INPUT_PROMPT: &str = "> ";
 /// Key help shown before the global autoreply shortcut.
 const KEY_HELP_BEFORE_GLOBAL: &str =
-    "Tab:focus  Enter:apri/invia  F2:chat/contatti  Canc:elimina  Ctrl+A:AI chat  ";
+    "Tab:focus  Enter:apri/invia  F2:chat/contatti  Canc:elimina  F3:AI chat  ";
 /// Key help of the global autoreply shortcut, highlighted when it is on.
-const KEY_HELP_GLOBAL: &str = "Ctrl+Shift+A:AI tutte";
+const KEY_HELP_GLOBAL: &str = "F4:AI tutte";
 /// Key help shown after the global autoreply shortcut.
-const KEY_HELP_AFTER_GLOBAL: &str = "  Ctrl+Q:esci";
+const KEY_HELP_AFTER_GLOBAL: &str = "  F10:esci";
 /// Gap between the status text and the key help.
 const FOOTER_GAP: &str = "   ";
 /// Leading space of the footer line.
@@ -592,8 +592,10 @@ mod tests {
             text,
             format!("{KEY_HELP_BEFORE_GLOBAL}{KEY_HELP_GLOBAL}{KEY_HELP_AFTER_GLOBAL}")
         );
-        assert!(text.contains("Ctrl+Q:esci"));
-        assert!(!text.contains("Ctrl+C"));
+        assert!(text.contains("F10:esci"));
+        assert!(text.contains("F3:AI chat"));
+        assert!(text.contains("F4:AI tutte"));
+        assert!(!text.contains("Ctrl"));
 
         let global_on = on.iter().find(|span| span.content == KEY_HELP_GLOBAL).unwrap();
         assert_eq!(global_on.style.fg, Some(AI_MARKER_COLOR));