Skip to main content

rat_widget_extra/
iban.rs

1//!
2//! Input field for IBAN bank account numbers.
3//!
4//! Verifies the checksum.
5//!
6//! Shows the bank account grouped by 4 chars and
7//! takes account of the different lengths per country.
8//!
9use crate::_private::NonExhaustive;
10use rat_event::{HandleEvent, MouseOnly, Regular};
11use rat_text::date_input::DateInputState;
12use rat_text::event::{ReadOnly, TextOutcome};
13use rat_text::text_input_mask::{MaskedInput, MaskedInputState};
14use rat_text::{
15    TextError, TextFocusGained, TextFocusLost, TextStyle, TextTab, derive_text_widget,
16    derive_text_widget_state,
17};
18use ratatui_core::buffer::Buffer;
19use ratatui_core::layout::Rect;
20use ratatui_core::style::Style;
21use ratatui_core::widgets::StatefulWidget;
22use ratatui_crossterm::crossterm::event::Event;
23use std::cmp::min;
24use std::str::FromStr;
25
26/// Widget for IBAN.
27#[derive(Debug, Default, Clone)]
28pub struct IBANInput<'a> {
29    widget: MaskedInput<'a>,
30}
31
32/// Widget state.
33#[derive(Debug, Clone)]
34pub struct IBANInputState {
35    /// __read only__ renewed with each render.
36    pub area: Rect,
37    /// __read only__ renewed with each render.
38    pub inner: Rect,
39    /// __read+write__  
40    pub widget: MaskedInputState,
41
42    /// __read only__ Current country mask.
43    pub country: String,
44
45    pub non_exhaustive: NonExhaustive,
46}
47
48impl<'a> IBANInput<'a> {
49    pub fn new() -> Self {
50        Self::default()
51    }
52}
53
54derive_text_widget!(IBANInput<'a>);
55
56impl<'a> StatefulWidget for &IBANInput<'a> {
57    type State = IBANInputState;
58
59    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
60        (&self.widget).render(area, buf, &mut state.widget);
61
62        state.area = state.widget.area;
63        state.inner = state.widget.inner;
64    }
65}
66
67impl StatefulWidget for IBANInput<'_> {
68    type State = IBANInputState;
69
70    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
71        self.widget.render(area, buf, &mut state.widget);
72
73        state.area = state.widget.area;
74        state.inner = state.widget.inner;
75    }
76}
77
78impl Default for IBANInputState {
79    fn default() -> Self {
80        let mut z = Self {
81            area: Default::default(),
82            inner: Default::default(),
83            widget: Default::default(),
84            country: Default::default(),
85            non_exhaustive: NonExhaustive,
86        };
87        _ = z.widget.set_mask("ll");
88        z
89    }
90}
91
92derive_text_widget_state!(IBANInputState);
93
94impl IBANInputState {
95    /// New state.
96    pub fn new() -> Self {
97        Self::default()
98    }
99
100    pub fn named(name: &str) -> Self {
101        let mut z = Self::default();
102        z.widget.focus = z.widget.focus.with_name(name);
103        z
104    }
105
106    /// Reset to empty.
107    #[inline]
108    pub fn clear(&mut self) {
109        self.widget.clear();
110    }
111
112    /// Validate the IBAN.
113    /// Sets the invalid flag.
114    /// Returns true if the IBAN is valid.
115    pub fn validate(&mut self) -> Result<bool, TextError> {
116        let iban = from_display_format(&self.country, self.widget.text());
117
118        if self.country.as_str() != country_code(&iban) {
119            let cursor = self.widget.cursor();
120            let anchor = self.widget.anchor();
121            self.widget.set_mask(pattern(&iban)).expect("valid_mask");
122            self.country = country_code(&iban).to_string();
123            self.widget.set_text(to_display_format(&iban));
124            self.widget.set_selection(anchor, cursor);
125        }
126
127        let r = if !iban.trim().is_empty() {
128            valid_iban_country(&iban) && valid_check_sum(&iban)
129        } else {
130            true
131        };
132
133        self.widget.set_invalid(!r);
134        Ok(r)
135    }
136
137    /// Set the IBAN.
138    ///
139    /// Invalid IBANs are acceptable, but they will
140    /// be truncated to 35 characters. If an invalid IBAN
141    /// is set the widget will immediately set the invalid
142    /// flag too.
143    ///
144    pub fn set_value(&mut self, iban: impl AsRef<str>) {
145        let iban = iban.as_ref();
146        self.country = country_code(iban).to_string();
147        self.widget.set_mask(pattern(iban)).expect("valid mask");
148        self.widget.set_text(to_display_format(iban));
149        let valid = if !iban.trim().is_empty() {
150            valid_iban_country(&iban) && valid_check_sum(&iban)
151        } else {
152            true
153        };
154        self.widget.set_invalid(!valid);
155    }
156
157    /// Get the IBAN.
158    ///
159    /// This will return invalid IBANs too.
160    pub fn value(&self) -> String {
161        let txt = self.widget.text();
162        from_display_format(&self.country, txt)
163    }
164
165    /// Get the IBAN.
166    ///
167    /// This will only return valid IBANs or empty strings.
168    pub fn valid_value(&self) -> Result<String, TextError> {
169        let txt = self.widget.text();
170        let iban = from_display_format(&self.country, txt);
171        if iban.trim().is_empty() {
172            Ok(Default::default())
173        } else if is_valid_iban(&iban) {
174            Ok(iban)
175        } else {
176            Err(TextError::InvalidValue)
177        }
178    }
179}
180
181impl HandleEvent<Event, Regular, TextOutcome> for IBANInputState {
182    fn handle(&mut self, event: &Event, _keymap: Regular) -> TextOutcome {
183        match self.widget.handle(event, Regular) {
184            TextOutcome::TextChanged => {
185                if let Err(_) = self.validate() {
186                    self.set_invalid(true);
187                }
188                TextOutcome::TextChanged
189            }
190            r => r,
191        }
192    }
193}
194
195impl HandleEvent<Event, ReadOnly, TextOutcome> for IBANInputState {
196    fn handle(&mut self, event: &Event, _keymap: ReadOnly) -> TextOutcome {
197        self.widget.handle(event, ReadOnly)
198    }
199}
200
201impl HandleEvent<Event, MouseOnly, TextOutcome> for IBANInputState {
202    fn handle(&mut self, event: &Event, _keymap: MouseOnly) -> TextOutcome {
203        self.widget.handle(event, MouseOnly)
204    }
205}
206
207/// Handle all events.
208/// Text events are only processed if focus is true.
209/// Mouse events are processed if they are in range.
210pub fn handle_events(state: &mut IBANInputState, focus: bool, event: &Event) -> TextOutcome {
211    state.widget.focus.set(focus);
212    state.handle(event, Regular)
213}
214
215/// Handle only navigation events.
216/// Text events are only processed if focus is true.
217/// Mouse events are processed if they are in range.
218pub fn handle_readonly_events(
219    state: &mut IBANInputState,
220    focus: bool,
221    event: &Event,
222) -> TextOutcome {
223    state.widget.focus.set(focus);
224    state.handle(event, ReadOnly)
225}
226
227/// Handle only mouse-events.
228pub fn handle_mouse_events(state: &mut DateInputState, event: &Event) -> TextOutcome {
229    state.handle(event, MouseOnly)
230}
231
232/// Is the IBAN correct.
233/// - valid country code
234/// - valid length
235/// - valid checksum
236pub fn is_valid_iban(iban: &str) -> bool {
237    if !valid_iban_country(iban) {
238        return false;
239    }
240    if iban_len(iban) != Some(iban.len() as u8) {
241        return false;
242    }
243    if !valid_check_sum(iban) {
244        return false;
245    }
246    true
247}
248
249static IBAN: &'static [(&'static str, u8)] = &[
250    ("AD", 24),
251    ("AE", 23),
252    ("AL", 28),
253    ("AT", 20),
254    ("AZ", 28),
255    ("BA", 20),
256    ("BE", 16),
257    ("BG", 22),
258    ("BH", 22),
259    ("BR", 29),
260    ("BY", 28),
261    ("CH", 21),
262    ("CR", 22),
263    ("CY", 28),
264    ("CZ", 24),
265    ("DE", 22),
266    ("DK", 18),
267    ("DO", 28),
268    ("EE", 20),
269    ("EG", 29),
270    ("ES", 24),
271    ("FI", 18),
272    ("FO", 18),
273    ("FR", 27),
274    ("GB", 22),
275    ("GE", 22),
276    ("GI", 23),
277    ("GL", 18),
278    ("GR", 27),
279    ("GT", 28),
280    ("HR", 21),
281    ("HU", 28),
282    ("IE", 22),
283    ("IL", 23),
284    ("IQ", 23),
285    ("IS", 26),
286    ("IT", 27),
287    ("JO", 30),
288    ("KW", 30),
289    ("KZ", 20),
290    ("LB", 28),
291    ("LC", 32),
292    ("LI", 21),
293    ("LT", 20),
294    ("LU", 20),
295    ("LV", 21),
296    ("MC", 27),
297    ("MD", 24),
298    ("ME", 22),
299    ("MK", 19),
300    ("MR", 27),
301    ("MT", 31),
302    ("MU", 30),
303    ("NL", 18),
304    ("NO", 15),
305    ("PK", 24),
306    ("PL", 28),
307    ("PS", 29),
308    ("PT", 25),
309    ("QA", 29),
310    ("RO", 24),
311    ("RS", 22),
312    ("SA", 24),
313    ("SC", 31),
314    ("SE", 24),
315    ("SI", 19),
316    ("SK", 24),
317    ("SM", 27),
318    ("ST", 25),
319    ("SV", 28),
320    ("TL", 23),
321    ("TN", 24),
322    ("TR", 26),
323    ("UA", 29),
324    ("VG", 24),
325    ("XK", 20),
326];
327
328fn country_code(iban: &str) -> &str {
329    let mut cit = iban.char_indices();
330    let mut c_end = 0;
331    cit.next();
332    if let Some(c) = cit.next() {
333        c_end = c.0 + c.1.len_utf8();
334    }
335    &iban[0..c_end]
336}
337
338fn enc(c: char, buf: &mut String) -> bool {
339    match c {
340        '0'..='9' => buf.push(c),
341        'a'..='z' => buf.push_str(format!("{}", (c as u32 - 'a' as u32) + 10).as_str()),
342        'A'..='Z' => buf.push_str(format!("{}", (c as u32 - 'A' as u32) + 10).as_str()),
343        ' ' => { /* noop */ }
344        _ => return false,
345    }
346    true
347}
348
349fn valid_check_sum(iban: &str) -> bool {
350    let mut cit = iban.chars();
351    let Some(c0) = cit.next() else {
352        return false;
353    };
354    let Some(c1) = cit.next() else {
355        return false;
356    };
357    let Some(c2) = cit.next() else {
358        return false;
359    };
360    let Some(c3) = cit.next() else {
361        return false;
362    };
363
364    let mut buf = String::new();
365    for c in cit {
366        if !enc(c, &mut buf) {
367            return false;
368        }
369    }
370    if !enc(c0, &mut buf) {
371        return false;
372    }
373    if !enc(c1, &mut buf) {
374        return false;
375    }
376    if !enc(c2, &mut buf) {
377        return false;
378    }
379    if !enc(c3, &mut buf) {
380        return false;
381    }
382    let buf = buf.as_str();
383
384    let mut c0 = 0;
385    let mut c1 = min(buf.len(), 18);
386    let mut r = 0;
387    loop {
388        let mut v = u64::from_str(&buf[c0..c1]).expect("integer");
389
390        v += r * 10u64.pow(v.ilog10() + 1);
391        r = v % 97;
392
393        c0 = c1;
394        c1 = min(buf.len(), c1 + 18);
395
396        if c0 == c1 {
397            break;
398        }
399    }
400
401    r == 1
402}
403
404fn valid_iban_country(iban: &str) -> bool {
405    let cc = country_code(iban);
406    for v in IBAN {
407        if v.0 == cc {
408            return true;
409        }
410    }
411    false
412}
413
414/// IBAN length derived from the country-code in
415/// the first two chars of the string.
416fn iban_len(iban: &str) -> Option<u8> {
417    let cc = country_code(iban);
418    for v in IBAN {
419        if v.0 == cc {
420            return Some(v.1);
421        }
422    }
423    None
424}
425
426fn pattern(iban: &str) -> String {
427    if let Some(len) = iban_len(iban) {
428        let mut buf = String::new();
429        buf.push_str("lldd ");
430        for i in 0..len - 4 {
431            if i > 0 && i % 4 == 0 {
432                buf.push(' ');
433            }
434            buf.push('a');
435        }
436        buf
437    } else {
438        "___________________________________".to_string()
439    }
440}
441
442fn to_display_format(iban: &str) -> String {
443    if valid_iban_country(iban) {
444        let mut buf = String::new();
445        for (i, c) in iban.chars().enumerate() {
446            if i > 0 && i % 4 == 0 {
447                buf.push(' ');
448            }
449            buf.push(c);
450        }
451        buf
452    } else {
453        iban.to_string()
454    }
455}
456
457fn from_display_format(cc: &str, iban: &str) -> String {
458    if valid_iban_country(cc) {
459        let mut buf = String::new();
460        for (i, c) in iban.chars().enumerate() {
461            if (i + 1) % 5 != 0 {
462                buf.push(c);
463            }
464        }
465        buf
466    } else {
467        iban.to_string()
468    }
469}