|
|
@@ -21,8 +21,9 @@ 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 = 'c';
|
|
|
-/// Character that, combined with Ctrl, toggles the autoreply of the open chat.
|
|
|
+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';
|
|
|
/// Sender jid stored for messages written by the user (own account).
|
|
|
const OWN_SENDER_JID: &str = "";
|
|
|
@@ -36,6 +37,8 @@ const STATUS_LOGGED_OUT: &str = "Sessione scollegata";
|
|
|
/// Status shown when the autoreply cannot be enabled without an AI client.
|
|
|
const STATUS_AI_NOT_CONFIGURED: &str =
|
|
|
"AI non configurata: imposta openai_api_key e system_prompt in config.toml";
|
|
|
+/// Prefix of the status shown when the global autoreply is toggled (followed by ON/OFF).
|
|
|
+const STATUS_GLOBAL_AUTOREPLY_PREFIX: &str = "Autoreply globale";
|
|
|
/// Prefix of the status shown when the AI client cannot be built.
|
|
|
const STATUS_AI_UNAVAILABLE_PREFIX: &str = "AI non disponibile";
|
|
|
/// Prefix of the status shown when the AI request fails.
|
|
|
@@ -52,6 +55,16 @@ const STATUS_FALLBACK_ERROR_PREFIX: &str = "Fallback cortesia fallito";
|
|
|
const STATUS_HISTORY_ERROR_PREFIX: &str = "Errore storico";
|
|
|
/// Prefix of the status shown when a message cannot be sent.
|
|
|
const STATUS_SEND_FAILED_PREFIX: &str = "Invio fallito";
|
|
|
+/// Status shown when a pending chat deletion is cancelled by another key.
|
|
|
+const STATUS_DELETE_CANCELLED: &str = "Eliminazione annullata";
|
|
|
+/// Prefix of the status asking to confirm the deletion of a chat (followed by `<name>?`).
|
|
|
+const STATUS_DELETE_CONFIRM_PREFIX: &str = "Eliminare la chat";
|
|
|
+/// Hint appended to the deletion confirmation question.
|
|
|
+const STATUS_DELETE_CONFIRM_HINT: &str = "Canc conferma, Esc annulla";
|
|
|
+/// Prefix of the status shown once a chat was deleted locally (followed by `<name>` and the suffix).
|
|
|
+const STATUS_CHAT_DELETED_PREFIX: &str = "Chat";
|
|
|
+/// Suffix of the status shown once a chat was deleted locally.
|
|
|
+const STATUS_CHAT_DELETED_SUFFIX: &str = "eliminata";
|
|
|
|
|
|
/// Event delivered to `App::handle_event` by the main loop and by background tasks.
|
|
|
#[derive(Debug)]
|
|
|
@@ -135,10 +148,15 @@ pub struct App {
|
|
|
pub contacts: Vec<Contact>,
|
|
|
/// Index in the active list.
|
|
|
pub selected: usize,
|
|
|
+ /// Text typed while the list has focus; filters chat and contact names, case-insensitive;
|
|
|
+ /// kept when switching list with F2.
|
|
|
+ pub filter: String,
|
|
|
/// Jid of the open chat.
|
|
|
pub open_chat: Option<String>,
|
|
|
pub open_chat_name: String,
|
|
|
pub open_chat_autoreply: bool,
|
|
|
+ /// AI autoreply enabled for every chat, regardless of per-chat flags.
|
|
|
+ pub global_autoreply: bool,
|
|
|
/// Open chat messages, ascending, last `MESSAGES_SHOWN`.
|
|
|
pub messages: Vec<StoredMessage>,
|
|
|
pub input: String,
|
|
|
@@ -153,6 +171,8 @@ pub struct App {
|
|
|
own_jids_known: bool,
|
|
|
/// Whether the catch-up after the offline sync already ran in this process.
|
|
|
catch_up_done: bool,
|
|
|
+ /// Jid of the chat awaiting the second Delete press that confirms its local deletion.
|
|
|
+ pending_delete: Option<String>,
|
|
|
wa: WaHandle,
|
|
|
events: UnboundedSender<AppEvent>,
|
|
|
}
|
|
|
@@ -167,6 +187,7 @@ impl App {
|
|
|
let (ai, status) = build_ai(&config);
|
|
|
let chats = history.list_chats()?;
|
|
|
let contacts = history.list_contacts()?;
|
|
|
+ let global_autoreply = history.is_global_autoreply()?;
|
|
|
let cooldowns = Cooldowns {
|
|
|
private: config.cooldown_seconds,
|
|
|
group: config.group_cooldown_seconds,
|
|
|
@@ -180,9 +201,11 @@ impl App {
|
|
|
chats,
|
|
|
contacts,
|
|
|
selected: 0,
|
|
|
+ filter: String::new(),
|
|
|
open_chat: None,
|
|
|
open_chat_name: String::new(),
|
|
|
open_chat_autoreply: false,
|
|
|
+ global_autoreply,
|
|
|
messages: Vec::new(),
|
|
|
input: String::new(),
|
|
|
status,
|
|
|
@@ -193,6 +216,7 @@ impl App {
|
|
|
gate: ReplyGate::new(now_unix(), cooldowns),
|
|
|
own_jids_known: false,
|
|
|
catch_up_done: false,
|
|
|
+ pending_delete: None,
|
|
|
wa,
|
|
|
events,
|
|
|
})
|
|
|
@@ -281,8 +305,8 @@ impl App {
|
|
|
self.maybe_autoreply(message, image, addressed_to)
|
|
|
}
|
|
|
|
|
|
- /// Purpose: start an AI completion if the gate allows an autoreply to `message`, or schedule
|
|
|
- /// one for the end of the cooldown.
|
|
|
+ /// Purpose: start an AI completion if the gate allows an autoreply to `message` (autoreply
|
|
|
+ /// enabled globally or for its chat), or schedule one for the end of the cooldown.
|
|
|
/// Inputs: `message`, the newly stored message; `image`, the reference to its image, if any;
|
|
|
/// `addressed_to`, the raw JIDs the message mentions or quotes (shortens the group cooldown).
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
@@ -303,7 +327,7 @@ impl App {
|
|
|
self.refresh_own_jids();
|
|
|
}
|
|
|
let now = now_unix();
|
|
|
- let enabled = self.history.is_autoreply(&message.chat_jid)?;
|
|
|
+ let enabled = self.autoreply_enabled(&message.chat_jid)?;
|
|
|
if self.gate.should_reply(message, enabled, addressed_to, now) {
|
|
|
return self.start_reply(ai, &prompt, &message.chat_jid, image, now);
|
|
|
}
|
|
|
@@ -318,11 +342,12 @@ impl App {
|
|
|
/// Inputs: `chat_jid`, the chat whose timer fired.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
/// Side effects: consumes the pending deferral; if it was still pending, the autoreply is
|
|
|
- /// still enabled and the AI is configured, records the reply in the gate and spawns a task
|
|
|
- /// that sends `AiReply`. The reply is text-only: an image appears as its placeholder in the
|
|
|
- /// context. Nothing happens when a live reply already cancelled the deferral.
|
|
|
+ /// still enabled (globally or per chat) and the AI is configured, records the reply in the
|
|
|
+ /// gate and spawns a task that sends `AiReply`. The reply is text-only: an image appears as
|
|
|
+ /// its placeholder in the context. Nothing happens when a live reply already cancelled the
|
|
|
+ /// deferral.
|
|
|
fn handle_cooldown_elapsed(&mut self, chat_jid: &str) -> Result<()> {
|
|
|
- if !self.gate.take_pending(chat_jid) || !self.history.is_autoreply(chat_jid)? {
|
|
|
+ if !self.gate.take_pending(chat_jid) || !self.autoreply_enabled(chat_jid)? {
|
|
|
return Ok(());
|
|
|
}
|
|
|
let Some((ai, prompt)) = self.ai_setup() else {
|
|
|
@@ -331,6 +356,14 @@ impl App {
|
|
|
self.start_reply(ai, &prompt, chat_jid, None, now_unix())
|
|
|
}
|
|
|
|
|
|
+ /// Purpose: tell whether the AI autoreply applies to a chat.
|
|
|
+ /// Inputs: `chat_jid`, the chat to check.
|
|
|
+ /// Output: `true` when the global autoreply or the per-chat flag is on, or a history error.
|
|
|
+ /// Side effects: reads the history (skipped when the global autoreply is on).
|
|
|
+ fn autoreply_enabled(&self, chat_jid: &str) -> Result<bool> {
|
|
|
+ Ok(self.global_autoreply || self.history.is_autoreply(chat_jid)?)
|
|
|
+ }
|
|
|
+
|
|
|
/// Purpose: run the catch-up once per process when the offline messages have been delivered.
|
|
|
/// Inputs: none.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
@@ -370,12 +403,13 @@ impl App {
|
|
|
|
|
|
/// Purpose: find the chats whose last message deserves a catch-up reply.
|
|
|
/// Inputs: `now`, unix seconds.
|
|
|
- /// Output: the JIDs of the chats with the autoreply enabled whose latest stored message
|
|
|
- /// passes `ReplyGate::catch_up_due`, or a history error.
|
|
|
+ /// Output: the JIDs of the chats with the autoreply enabled (globally or per chat) whose
|
|
|
+ /// latest stored message passes `ReplyGate::catch_up_due`, or a history error.
|
|
|
/// Side effects: reads the history.
|
|
|
fn catch_up_chats(&self, now: i64) -> Result<Vec<String>> {
|
|
|
let mut due = Vec::new();
|
|
|
- for chat in self.history.list_chats()?.into_iter().filter(|chat| chat.autoreply) {
|
|
|
+ let chats = self.history.list_chats()?;
|
|
|
+ for chat in chats.into_iter().filter(|chat| self.global_autoreply || chat.autoreply) {
|
|
|
let Some(last) = self.history.recent_messages(&chat.jid, 1)?.pop() else {
|
|
|
continue;
|
|
|
};
|
|
|
@@ -465,7 +499,7 @@ impl App {
|
|
|
/// Purpose: handle the outcome of an AI completion.
|
|
|
/// Inputs: `chat_jid`, the chat to answer; `result`, the generated text or an error text.
|
|
|
/// Output: none.
|
|
|
- /// Side effects: on success, if the autoreply is still enabled, spawns a send task; if it was
|
|
|
+ /// Side effects: on success, if the autoreply is still enabled (globally or per chat), spawns a send task; if it was
|
|
|
/// disabled meanwhile or the lookup fails, nothing is sent and the status explains why; on
|
|
|
/// failure sets the error status.
|
|
|
fn handle_ai_reply(&mut self, chat_jid: String, result: Result<String, String>) {
|
|
|
@@ -475,13 +509,14 @@ impl App {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /// Purpose: send a generated AI reply only if the chat still has the autoreply enabled.
|
|
|
+ /// Purpose: send a generated AI reply only if the chat still has the autoreply enabled
|
|
|
+ /// (globally or per chat).
|
|
|
/// Inputs: `chat_jid`, the chat to answer; `text`, the generated reply.
|
|
|
/// Output: none.
|
|
|
/// Side effects: spawns a send task when enabled; otherwise sets the discard or history
|
|
|
/// error status and sends nothing.
|
|
|
fn send_ai_reply_if_enabled(&mut self, chat_jid: String, text: String) {
|
|
|
- match self.history.is_autoreply(&chat_jid) {
|
|
|
+ match self.autoreply_enabled(&chat_jid) {
|
|
|
Ok(true) => {
|
|
|
let outgoing = OutgoingMessage { chat_jid, text, is_ai: true };
|
|
|
spawn_send(self.wa.clone(), self.events.clone(), outgoing);
|
|
|
@@ -535,13 +570,20 @@ impl App {
|
|
|
return Ok(());
|
|
|
}
|
|
|
if ctrl {
|
|
|
+ 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(()),
|
|
|
};
|
|
|
}
|
|
|
match (key.code, self.focus) {
|
|
|
- (KeyCode::Tab, _) => self.focus = toggled_focus(self.focus),
|
|
|
+ (KeyCode::Tab, _) => {
|
|
|
+ self.cancel_pending_delete();
|
|
|
+ self.focus = toggled_focus(self.focus);
|
|
|
+ }
|
|
|
(KeyCode::F(LIST_MODE_FUNCTION_KEY), _) => self.toggle_list_mode(),
|
|
|
(code, Focus::List) => return self.handle_list_key(code),
|
|
|
(code, Focus::Input) => self.handle_input_key(code),
|
|
|
@@ -551,18 +593,121 @@ impl App {
|
|
|
|
|
|
/// Purpose: handle a key while the list has the focus.
|
|
|
/// Inputs: `code`, the key code.
|
|
|
- /// Output: `Ok(())`, or a history error when opening a chat.
|
|
|
- /// Side effects: moves the selection or opens the selected chat.
|
|
|
+ /// Output: `Ok(())`, or a history error when opening or deleting a chat.
|
|
|
+ /// Side effects: any key but Delete cancels a pending deletion (Esc is then consumed); moves
|
|
|
+ /// the selection, edits the filter, opens the selected chat or asks/confirms a chat deletion.
|
|
|
fn handle_list_key(&mut self, code: KeyCode) -> Result<()> {
|
|
|
+ if code == KeyCode::Delete {
|
|
|
+ return self.request_delete();
|
|
|
+ }
|
|
|
+ if self.cancel_pending_delete() && code == KeyCode::Esc {
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
match code {
|
|
|
KeyCode::Up => self.selected = step_selection(self.selected, self.active_len(), -1),
|
|
|
KeyCode::Down => self.selected = step_selection(self.selected, self.active_len(), 1),
|
|
|
KeyCode::Enter => return self.open_selected(),
|
|
|
+ KeyCode::Char(c) => self.edit_filter(|filter| filter.push(c)),
|
|
|
+ KeyCode::Backspace => self.edit_filter(|filter| {
|
|
|
+ filter.pop();
|
|
|
+ }),
|
|
|
+ KeyCode::Esc => self.edit_filter(String::clear),
|
|
|
_ => {}
|
|
|
}
|
|
|
Ok(())
|
|
|
}
|
|
|
|
|
|
+ /// Purpose: apply an edit to the list filter.
|
|
|
+ /// Inputs: `edit`, the mutation to apply to `filter`.
|
|
|
+ /// Output: none.
|
|
|
+ /// Side effects: changes `filter` and moves the selection to the first row.
|
|
|
+ fn edit_filter(&mut self, edit: impl FnOnce(&mut String)) {
|
|
|
+ edit(&mut self.filter);
|
|
|
+ self.selected = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: drop a pending chat deletion, if any.
|
|
|
+ /// Inputs: none.
|
|
|
+ /// Output: `true` if a deletion was pending.
|
|
|
+ /// Side effects: clears `pending_delete` and, when it was set, shows the cancellation status.
|
|
|
+ fn cancel_pending_delete(&mut self) -> bool {
|
|
|
+ let was_pending = self.pending_delete.take().is_some();
|
|
|
+ if was_pending {
|
|
|
+ self.status = STATUS_DELETE_CANCELLED.to_owned();
|
|
|
+ }
|
|
|
+ was_pending
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: handle Delete on the list: ask for confirmation, or confirm on the second press.
|
|
|
+ /// Inputs: none (uses the selected visible chat and `pending_delete`).
|
|
|
+ /// Output: `Ok(())`, or a history error when the deletion is confirmed.
|
|
|
+ /// Side effects: sets `pending_delete` and `status`; on confirmation deletes the chat locally.
|
|
|
+ /// Does nothing in contacts mode or without a selected chat.
|
|
|
+ fn request_delete(&mut self) -> Result<()> {
|
|
|
+ if self.list_mode != ListMode::Chats {
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ let Some((jid, name)) = self
|
|
|
+ .visible_chats()
|
|
|
+ .get(self.selected)
|
|
|
+ .map(|chat| (chat.jid.clone(), chat.name.clone()))
|
|
|
+ else {
|
|
|
+ return Ok(());
|
|
|
+ };
|
|
|
+ if self.pending_delete.as_deref() == Some(jid.as_str()) {
|
|
|
+ return self.confirm_delete(jid, name);
|
|
|
+ }
|
|
|
+ self.status = format!("{STATUS_DELETE_CONFIRM_PREFIX} {name}? {STATUS_DELETE_CONFIRM_HINT}");
|
|
|
+ self.pending_delete = Some(jid);
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: delete a chat from the local history only (no WhatsApp call).
|
|
|
+ /// Inputs: `jid`, the chat to delete; `name`, its display name for the status.
|
|
|
+ /// Output: `Ok(())`, or a history error.
|
|
|
+ /// Side effects: deletes the chat from the history, drops its deferred AI reply, closes it if
|
|
|
+ /// open, reloads the chat list and updates `status`; always clears `pending_delete`.
|
|
|
+ fn confirm_delete(&mut self, jid: String, name: String) -> Result<()> {
|
|
|
+ self.pending_delete = None;
|
|
|
+ self.history.delete_chat(&jid)?;
|
|
|
+
|
|
|
+ // The returned flag only tells whether a reply was deferred: nothing to do either way.
|
|
|
+ let _ = self.gate.take_pending(&jid);
|
|
|
+ if self.open_chat.as_deref() == Some(jid.as_str()) {
|
|
|
+ self.close_open_chat();
|
|
|
+ }
|
|
|
+ self.reload_chats()?;
|
|
|
+ self.status = format!("{STATUS_CHAT_DELETED_PREFIX} {name} {STATUS_CHAT_DELETED_SUFFIX}");
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: close the open chat.
|
|
|
+ /// Inputs: none.
|
|
|
+ /// Output: none.
|
|
|
+ /// Side effects: clears `open_chat`, its name, autoreply flag and messages.
|
|
|
+ fn close_open_chat(&mut self) {
|
|
|
+ self.open_chat = None;
|
|
|
+ self.open_chat_name.clear();
|
|
|
+ self.open_chat_autoreply = false;
|
|
|
+ self.messages.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: chats whose name matches the current filter.
|
|
|
+ /// Inputs: none.
|
|
|
+ /// Output: the matching chats, in list order.
|
|
|
+ /// Side effects: none.
|
|
|
+ pub fn visible_chats(&self) -> Vec<&ChatSummary> {
|
|
|
+ self.chats.iter().filter(|chat| matches_filter(&chat.name, &self.filter)).collect()
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Purpose: contacts whose name matches the current filter.
|
|
|
+ /// Inputs: none.
|
|
|
+ /// Output: the matching contacts, in list order.
|
|
|
+ /// Side effects: none.
|
|
|
+ pub fn visible_contacts(&self) -> Vec<&Contact> {
|
|
|
+ self.contacts.iter().filter(|contact| matches_filter(&contact.name, &self.filter)).collect()
|
|
|
+ }
|
|
|
+
|
|
|
/// Purpose: handle a key while the input line has the focus.
|
|
|
/// Inputs: `code`, the key code.
|
|
|
/// Output: none.
|
|
|
@@ -596,8 +741,10 @@ impl App {
|
|
|
/// Purpose: switch between chat list and contact list.
|
|
|
/// Inputs: none.
|
|
|
/// Output: none.
|
|
|
- /// Side effects: changes `list_mode` and resets the selection to the first row.
|
|
|
+ /// Side effects: changes `list_mode`, resets the selection to the first row and cancels a
|
|
|
+ /// pending chat deletion; the filter is kept.
|
|
|
fn toggle_list_mode(&mut self) {
|
|
|
+ self.cancel_pending_delete();
|
|
|
self.list_mode = toggled_list_mode(self.list_mode);
|
|
|
self.selected = 0;
|
|
|
}
|
|
|
@@ -623,14 +770,35 @@ impl App {
|
|
|
self.reload_chats()
|
|
|
}
|
|
|
|
|
|
+ /// Purpose: toggle the global AI autoreply, which answers every chat regardless of the
|
|
|
+ /// per-chat flags.
|
|
|
+ /// Inputs: none.
|
|
|
+ /// Output: `Ok(())`, or a history error.
|
|
|
+ /// Side effects: writes the persistent global flag, updates `global_autoreply` and `status`;
|
|
|
+ /// refuses to enable without an AI client (disabling is always allowed).
|
|
|
+ fn toggle_global_autoreply(&mut self) -> Result<()> {
|
|
|
+ let enable = !self.global_autoreply;
|
|
|
+ if enable && self.ai.is_none() {
|
|
|
+ self.status = STATUS_AI_NOT_CONFIGURED.to_owned();
|
|
|
+ return Ok(());
|
|
|
+ }
|
|
|
+ self.history.set_global_autoreply(enable)?;
|
|
|
+ self.global_autoreply = enable;
|
|
|
+ let state = if enable { "ON" } else { "OFF" };
|
|
|
+ self.status = format!("{STATUS_GLOBAL_AUTOREPLY_PREFIX} {state}");
|
|
|
+ Ok(())
|
|
|
+ }
|
|
|
+
|
|
|
/// Purpose: open the chat or contact selected in the active list.
|
|
|
/// Inputs: none.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
/// Side effects: loads name, autoreply flag and messages; moves the focus to the input.
|
|
|
fn open_selected(&mut self) -> Result<()> {
|
|
|
let jid = match self.list_mode {
|
|
|
- ListMode::Chats => self.chats.get(self.selected).map(|chat| chat.jid.clone()),
|
|
|
- ListMode::Contacts => self.contacts.get(self.selected).map(|c| c.jid.clone()),
|
|
|
+ ListMode::Chats => self.visible_chats().get(self.selected).map(|chat| chat.jid.clone()),
|
|
|
+ ListMode::Contacts => {
|
|
|
+ self.visible_contacts().get(self.selected).map(|contact| contact.jid.clone())
|
|
|
+ }
|
|
|
};
|
|
|
let Some(jid) = jid else {
|
|
|
return Ok(());
|
|
|
@@ -645,12 +813,12 @@ impl App {
|
|
|
|
|
|
/// Purpose: number of rows of the active list.
|
|
|
/// Inputs: none.
|
|
|
- /// Output: length of the chat or contact list according to `list_mode`.
|
|
|
+ /// Output: length of the visible (filtered) chat or contact list according to `list_mode`.
|
|
|
/// Side effects: none.
|
|
|
fn active_len(&self) -> usize {
|
|
|
match self.list_mode {
|
|
|
- ListMode::Chats => self.chats.len(),
|
|
|
- ListMode::Contacts => self.contacts.len(),
|
|
|
+ ListMode::Chats => self.visible_chats().len(),
|
|
|
+ ListMode::Contacts => self.visible_contacts().len(),
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -694,6 +862,14 @@ fn now_unix() -> i64 {
|
|
|
chrono::Utc::now().timestamp()
|
|
|
}
|
|
|
|
|
|
+/// Purpose: tell whether a list entry name passes the list filter.
|
|
|
+/// Inputs: `name`, the chat or contact name; `filter`, the text typed by the user.
|
|
|
+/// Output: `true` if `filter` is empty or contained in `name`, ignoring case.
|
|
|
+/// Side effects: none.
|
|
|
+fn matches_filter(name: &str, filter: &str) -> bool {
|
|
|
+ filter.is_empty() || name.to_lowercase().contains(&filter.to_lowercase())
|
|
|
+}
|
|
|
+
|
|
|
/// Purpose: keep a selection index inside a list.
|
|
|
/// Inputs: `selected`, the current index; `len`, the list length.
|
|
|
/// Output: `selected` limited to the last row, or 0 for an empty list.
|
|
|
@@ -906,6 +1082,20 @@ 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::*;
|
|
|
@@ -957,6 +1147,15 @@ mod tests {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+ #[test]
|
|
|
+ fn matches_filter_is_case_insensitive_and_empty_matches_all() {
|
|
|
+ assert!(matches_filter("Mario Rossi", "mar"));
|
|
|
+ assert!(!matches_filter("Mario", "ROSSI"));
|
|
|
+ assert!(matches_filter("Mario Rossi", "ROSSI"));
|
|
|
+ assert!(matches_filter("x", ""));
|
|
|
+ assert!(matches_filter("Àlex", "àl"));
|
|
|
+ }
|
|
|
+
|
|
|
#[test]
|
|
|
fn clamped_selection_stays_inside_list() {
|
|
|
assert_eq!(clamped_selection(5, 3), 2);
|
|
|
@@ -988,4 +1187,23 @@ mod tests {
|
|
|
assert!(!is_command(KeyModifiers::NONE));
|
|
|
assert!(is_command(KeyModifiers::CONTROL | KeyModifiers::SHIFT));
|
|
|
}
|
|
|
+
|
|
|
+ #[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');
|
|
|
+ }
|
|
|
}
|