classicube_helpers\tab_list/
mod.rs

1mod entry;
2use std::{
3    cell::RefCell,
4    collections::HashMap,
5    rc::{Rc, Weak},
6};
7
8use classicube_sys::{TabList, TABLIST_MAX_NAMES};
9use tracing::warn;
10
11pub use self::entry::TabListEntry;
12use crate::{
13    callback_handler::CallbackHandler,
14    events::{net, tab_list},
15};
16
17/// safe access to `TabList`
18#[derive(Default)]
19pub struct TabList {
20    entries: Rc<RefCell<HashMap<u8, Rc<TabListEntry>>>>,
21
22    #[allow(clippy::type_complexity)]
23    added_callbacks: Rc<RefCell<CallbackHandler<(u8, Weak<TabListEntry>)>>>,
24    #[allow(dead_code)]
25    added_handler: tab_list::AddedEventHandler,
26
27    #[allow(clippy::type_complexity)]
28    changed_callbacks: Rc<RefCell<CallbackHandler<(u8, Weak<TabListEntry>)>>>,
29    #[allow(dead_code)]
30    changed_handler: tab_list::ChangedEventHandler,
31
32    removed_callbacks: Rc<RefCell<CallbackHandler<u8>>>,
33    #[allow(dead_code)]
34    removed_handler: tab_list::RemovedEventHandler,
35
36    disconnected_handler: net::DisconnectedEventHandler,
37}
38
39impl TabList {
40    /// register event listeners, listeners will unregister on drop
41    #[must_use]
42    pub fn new() -> Self {
43        let entries = HashMap::with_capacity(256);
44        let entries = Rc::new(RefCell::new(entries));
45
46        let added_callbacks = Rc::new(RefCell::new(CallbackHandler::new()));
47        let mut added_handler = tab_list::AddedEventHandler::new();
48        {
49            let entries = entries.clone();
50            let added_callbacks = added_callbacks.clone();
51            added_handler.on(move |tab_list::AddedEvent { id }| {
52                let id = *id;
53                let entry = Rc::new(match unsafe { TabListEntry::from_id(id) } {
54                    None => {
55                        warn!(?id, "AddedEvent TabListEntry::from_id returned None");
56                        return;
57                    }
58                    Some(entry) => entry,
59                });
60                let weak = Rc::downgrade(&entry);
61
62                {
63                    let mut entries = entries.borrow_mut();
64                    entries.insert(id, entry);
65                }
66
67                let mut added_callbacks = added_callbacks.borrow_mut();
68                added_callbacks.handle_event(&(id, weak));
69            });
70        }
71
72        let changed_callbacks = Rc::new(RefCell::new(CallbackHandler::new()));
73        let mut changed_handler = tab_list::ChangedEventHandler::new();
74        {
75            let entries = entries.clone();
76            let changed_callbacks = changed_callbacks.clone();
77            changed_handler.on(move |tab_list::ChangedEvent { id }| {
78                let id = *id;
79
80                let entry = Rc::new(match unsafe { TabListEntry::from_id(id) } {
81                    None => {
82                        warn!(?id, "ChangedEvent TabListEntry::from_id returned None");
83                        return;
84                    }
85                    Some(entry) => entry,
86                });
87                let weak = Rc::downgrade(&entry);
88                {
89                    let mut entries = entries.borrow_mut();
90                    entries.entry(id).or_insert(entry);
91                }
92
93                let mut changed_callbacks = changed_callbacks.borrow_mut();
94                changed_callbacks.handle_event(&(id, weak));
95            });
96        }
97
98        let removed_callbacks = Rc::new(RefCell::new(CallbackHandler::new()));
99        let mut removed_handler = tab_list::RemovedEventHandler::new();
100        {
101            let entries = entries.clone();
102            let removed_callbacks = removed_callbacks.clone();
103            removed_handler.on(move |tab_list::RemovedEvent { id }| {
104                {
105                    let mut entries = entries.borrow_mut();
106                    entries.remove(id);
107                }
108
109                let mut removed_callbacks = removed_callbacks.borrow_mut();
110                removed_callbacks.handle_event(id);
111            });
112        }
113
114        let mut disconnected_handler = net::DisconnectedEventHandler::new();
115        {
116            let entries = entries.clone();
117            disconnected_handler.on(move |_| {
118                let mut entries = entries.borrow_mut();
119                entries.clear();
120            });
121        }
122
123        let mut s = Self {
124            entries,
125            added_callbacks,
126            added_handler,
127            changed_callbacks,
128            changed_handler,
129            removed_callbacks,
130            removed_handler,
131            disconnected_handler,
132        };
133
134        s.update_to_real_entries();
135
136        s
137    }
138
139    fn update_to_real_entries(&mut self) {
140        let mut entries = self.entries.borrow_mut();
141        entries.clear();
142
143        for id in 0..TABLIST_MAX_NAMES {
144            unsafe {
145                if TabList.NameOffsets[id as usize] != 0 {
146                    if let Some(entry) = TabListEntry::from_id(u8::try_from(id).unwrap()) {
147                        entries.insert(u8::try_from(id).unwrap(), Rc::new(entry));
148                    }
149                }
150            }
151        }
152    }
153
154    pub fn on_added<F>(&mut self, callback: F)
155    where
156        F: FnMut(&(u8, Weak<TabListEntry>)),
157        F: 'static,
158    {
159        let mut added_callbacks = self.added_callbacks.borrow_mut();
160        added_callbacks.on(callback);
161    }
162
163    pub fn on_changed<F>(&mut self, callback: F)
164    where
165        F: FnMut(&(u8, Weak<TabListEntry>)),
166        F: 'static,
167    {
168        let mut changed_callbacks = self.changed_callbacks.borrow_mut();
169        changed_callbacks.on(callback);
170    }
171
172    pub fn on_removed<F>(&mut self, callback: F)
173    where
174        F: FnMut(&u8),
175        F: 'static,
176    {
177        let mut removed_callbacks = self.removed_callbacks.borrow_mut();
178        removed_callbacks.on(callback);
179    }
180
181    pub fn on_disconnected<F>(&mut self, callback: F)
182    where
183        F: FnMut(&net::DisconnectedEvent),
184        F: 'static,
185    {
186        self.disconnected_handler.on(callback);
187    }
188
189    fn best_match(&self, search: &str) -> Option<Weak<TabListEntry>> {
190        // tablist doesn't include <Local map chat> or [xtitles], so match from right to left
191        let entries = self.entries.borrow();
192        let mut positions: Vec<_> = entries
193            .values()
194            .filter_map(|entry| {
195                let nick_name = entry.get_nick_name().replace(" &7(AFK)", "");
196
197                // search: &0<Realm 7&0> &dAdo&elf Hit&aler
198                // entry :               ^
199                // &3[arsclacxe&3] &aPee&2birb
200                //                 ^
201                // &x<&xVIP&x> &x[&lGod's Architect&x] &x[&eΩ&x] Kylbert
202                //                                     ^
203                // &c[&4Co&4m&6mmu&4nist&c] TEHNOOBSHOW
204                //                          ^ (notice the color is at "&c[")
205                // &3SpiralP
206                // ^ (matched by exact)
207                // &7S0m
208                // ^ (matched by exact)
209
210                // fn remove_beginning_color(s: &str) -> &str {
211                //     if s.len() >= 2 && s.starts_with('&') {
212                //         let (_color, s) = s.split_at(2);
213                //         s
214                //     } else {
215                //         s
216                //     }
217                // }
218
219                // remove color
220                let search = remove_color(search);
221                let real_nick = remove_color(&nick_name);
222
223                // search in reverse
224                let search: String = search.chars().rev().collect();
225                let real_nick: String = real_nick.chars().rev().collect();
226
227                search.find(&real_nick).map(|pos| (entry, nick_name, pos))
228            })
229            .collect();
230
231        // searching from the end, right to left
232        // search = NotSpiralP
233        // SpiralP     pos = 0
234        // NotSpiralP  pos = 0
235        // SpiralP2    not found
236
237        // choose smallest find position (most matched from end to start)
238        // then choose largest name size for equal positions
239        positions.sort_unstable_by(|(_entry1, name1, pos1), (_entry2, name2, pos2)| {
240            pos1.partial_cmp(pos2)
241                .unwrap()
242                .then_with(|| name2.len().partial_cmp(&name1.len()).unwrap())
243        });
244
245        positions
246            .first()
247            .map(|(entry, _name, _pos)| Rc::downgrade(*entry))
248    }
249
250    #[must_use]
251    pub fn find_entry_by_nick_name(&self, search: &str) -> Option<Weak<TabListEntry>> {
252        let entries = self.entries.borrow();
253        let option = entries.values().find(|entry| {
254            // try exact nick_name match first
255            // this should match if there are no <Local> or tags on the front
256            let nick_name = entry.get_nick_name().replace(" &7(AFK)", "");
257            nick_name == search ||
258                // compare with colors removed
259                remove_color(&nick_name) == remove_color(search)
260        });
261
262        if let Some(a) = option {
263            Some(Rc::downgrade(a))
264        } else {
265            let option = entries.values().find(|entry| {
266                // try exact real_name match first
267                // this should match if there are no <Local> or tags on the front
268                let real_name = entry.get_real_name().replace(" &7(AFK)", "");
269                real_name == search ||
270                    // compare with colors removed
271                    remove_color(&real_name) == remove_color(search)
272            });
273
274            if let Some(a) = option {
275                Some(Rc::downgrade(a))
276            } else {
277                // exact match failed,
278                // match from the right, choose the one with most chars matched
279
280                self.best_match(search)
281            }
282        }
283    }
284
285    #[must_use]
286    pub fn get(&self, id: u8) -> Option<Weak<TabListEntry>> {
287        let entries = self.entries.borrow();
288        let entry = entries.get(&id)?;
289        Some(Rc::downgrade(entry))
290    }
291
292    #[must_use]
293    pub fn get_all(&self) -> Vec<(u8, Weak<TabListEntry>)> {
294        let entries = self.entries.borrow();
295        entries
296            .values()
297            .map(|entry| (entry.get_id(), Rc::downgrade(entry)))
298            .collect::<Vec<_>>()
299    }
300}
301
302#[cfg(all(windows, not(feature = "ci")))]
303#[ignore]
304#[test]
305fn test_find_entry_by_nick_name() {
306    use classicube_sys::*;
307
308    let tab_list = TabList::new();
309
310    let pairs = [
311        ("goodlyay", "&a&f�&a Goodly"),
312        ("SpiralP", "&u&bs&fp&6i&fr&ba"),
313        ("", "&9&9\u{b}&9 &rSp&9&3a&9c&1e"),
314        ("SpiralP2", "&7SpiralP2    &f0"),
315    ];
316
317    for (i, (real_nick, nick_name)) in pairs.iter().enumerate() {
318        unsafe {
319            let player = OwnedString::new(*real_nick);
320            let list = OwnedString::new(*nick_name);
321            let group = OwnedString::new("group");
322            TabList_Set(
323                i as _,
324                player.as_cc_string(),
325                list.as_cc_string(),
326                group.as_cc_string(),
327                0,
328            );
329
330            Event_RaiseInt(&mut TabListEvents.Added, i as _);
331        }
332    }
333
334    println!(
335        "{:#?}",
336        tab_list
337            .find_entry_by_nick_name("&7<Local>&u&u[&9Agg.Boo&9&u] &bs&fp&6i&fr&ba")
338            .unwrap()
339    );
340
341    println!(
342        "{:#?}",
343        tab_list
344            .find_entry_by_nick_name("&9[&b� &rBi&9r&1d &b�&9] &9\u{b}&9 &rSp&3a&9c&1e")
345            .unwrap()
346    );
347}
348
349#[cfg(all(windows, not(feature = "ci")))]
350#[ignore]
351#[test]
352fn test_match_names() {
353    let search = "NotSpiralP";
354    let names = vec!["hello", "SpiralP", "SpiralP2", "NotSpiralP", "SpiralP2"];
355
356    let mut positions: Vec<_> = names
357        .iter()
358        .filter_map(|nick_name| {
359            fn remove_beginning_color(s: &str) -> &str {
360                if s.len() >= 2 && s.starts_with('&') {
361                    let (_color, s) = s.split_at(2);
362                    s
363                } else {
364                    s
365                }
366            }
367
368            let search = remove_beginning_color(search);
369            let real_nick = remove_beginning_color(nick_name);
370
371            // search in reverse
372            let search: String = search.chars().rev().collect();
373            let real_nick: String = real_nick.chars().rev().collect();
374
375            search.find(&real_nick).map(|pos| (nick_name, pos))
376        })
377        .collect();
378
379    positions.sort_unstable_by(|(name1, pos1), (name2, pos2)| {
380        pos1.partial_cmp(pos2)
381            .unwrap()
382            .then_with(|| name2.len().partial_cmp(&name1.len()).unwrap())
383    });
384    println!("{:#?}", positions);
385}
386
387pub fn remove_color<T: AsRef<str>>(text: T) -> String {
388    let mut found_ampersand = false;
389
390    text.as_ref()
391        .chars()
392        .filter(|&c| {
393            if c == '&' {
394                // we remove all amps but they're kept in chat if repeated
395                found_ampersand = true;
396                false
397            } else if found_ampersand {
398                found_ampersand = false;
399                false
400            } else {
401                true
402            }
403        })
404        .collect()
405}
406
407#[test]
408fn test_remove_color() {
409    let pairs = [
410        ("SpiralP", "SpiralP"),
411        ("SpiralP", "SpiralP"),
412        ("SpiralP", "SpiralP"),
413        ("SpiralP", "&bS&fp&6i&fr&balP"),
414    ];
415
416    for (a, b) in &pairs {
417        assert_eq!(remove_color(b), *a);
418    }
419}