Skip to main content

pdfrum_form/
focus.rs

1//! Who has the keyboard, and the orderings that decide it.
2//!
3//! Focus belongs to the **document**, not to a page: one annotation has the
4//! keyboard at a time, whichever page it sits on.
5//!
6//! # The rules, and which are surprising
7//!
8//! - **Focusing what is already focused succeeds** without disturbing
9//!   anything. Re-clicking inside a field the user is already typing in moves
10//!   the caret and nothing else — it does not commit, and it does not clear
11//!   the undo stack.
12//! - **The outgoing field yields first.** Moving focus commits the field
13//!   losing it before the new one takes it, so a value is stored on the way
14//!   out rather than on the way in.
15//! - **A field keeps its state across focus changes.** Clicking away from a
16//!   half-typed field and back finds the typing still there — the state
17//!   record outlives the focus, and only a change to a *different* field
18//!   clears the undo stack.
19//! - **A right-click outside a field does not drop focus**, while a left
20//!   click on the same point does. That is not a mistake in the event
21//!   routing: the two buttons take different paths, and only one of them
22//!   kills focus on a miss.
23//! - **Clicking a point with no annotation drops focus**, and doing it again
24//!   is harmless rather than an error.
25
26use crate::session::{FocusTarget, FormSession};
27
28/// What a focus change did, and what the caller must do about it.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct FocusChange {
31    /// What had focus before.
32    pub from: Option<FocusTarget>,
33    /// What has it now.
34    pub to: Option<FocusTarget>,
35    /// Whether the field losing focus needs its value committed.
36    pub commit_outgoing: bool,
37    /// Whether the outgoing field's undo history should be discarded.
38    ///
39    /// Only when focus actually moved to a *different* field: moving the
40    /// caret inside one field keeps its history.
41    pub clear_undo: bool,
42}
43
44impl FocusChange {
45    /// Whether anything moved.
46    #[must_use]
47    pub fn moved(self) -> bool {
48        self.from != self.to
49    }
50
51    /// A change in which nothing happened.
52    #[must_use]
53    pub fn none(at: Option<FocusTarget>) -> FocusChange {
54        FocusChange {
55            from: at,
56            to: at,
57            commit_outgoing: false,
58            clear_undo: false,
59        }
60    }
61}
62
63/// Gives focus to `target`, taking it from whatever holds it.
64///
65/// Focusing the current holder is a success that changes nothing, which is
66/// what makes re-clicking inside a field cheap and non-destructive.
67pub fn set(session: &mut FormSession, target: FocusTarget) -> FocusChange {
68    let from = session.focus;
69    if from == Some(target) {
70        return FocusChange::none(from);
71    }
72
73    // The outgoing field commits before the incoming one takes over.
74    let commit_outgoing = from.is_some_and(|f| f.field().is_some());
75    // Undo history belongs to a field, so it survives a move that stays
76    // within one and is dropped by a move that leaves it.
77    let clear_undo = match (from.and_then(FocusTarget::field), target.field()) {
78        (Some(old), Some(new)) => old != new,
79        (Some(_), None) => true,
80        _ => false,
81    };
82
83    session.focus = Some(target);
84    session.drag = None;
85
86    FocusChange {
87        from,
88        to: Some(target),
89        commit_outgoing,
90        clear_undo,
91    }
92}
93
94/// Takes focus away from whatever holds it.
95///
96/// Doing this when nothing is focused is harmless and reports that nothing
97/// moved, rather than being an error.
98pub fn kill(session: &mut FormSession) -> FocusChange {
99    let from = session.focus;
100    if from.is_none() {
101        return FocusChange::none(None);
102    }
103
104    session.focus = None;
105    session.drag = None;
106
107    FocusChange {
108        from,
109        to: None,
110        commit_outgoing: from.is_some_and(|f| f.field().is_some()),
111        clear_undo: from.is_some_and(|f| f.field().is_some()),
112    }
113}
114
115/// Whether a click that hit nothing should drop focus.
116///
117/// The left button drops it; the right button does not, and neither does a
118/// wheel turn. This asymmetry is the reason a right-click far outside a
119/// focused field leaves it focused and rendering from its live editor state,
120/// while a left click on the very same point makes it fall back to a
121/// generated appearance.
122#[must_use]
123pub fn miss_drops_focus(button: crate::event::Button) -> bool {
124    match button {
125        crate::event::Button::Left => true,
126        crate::event::Button::Right => false,
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::event::Button;
134    use crate::session::{AnnotId, FieldId};
135
136    fn widget(field: u32, annot: u32) -> FocusTarget {
137        FocusTarget::Widget(FieldId(field), AnnotId::new(0, annot))
138    }
139
140    #[test]
141    fn focusing_from_nothing_takes_focus_and_commits_nothing() {
142        let mut session = FormSession::new();
143        let change = set(&mut session, widget(0, 1));
144
145        assert_eq!(session.focus, Some(widget(0, 1)));
146        assert_eq!(change.from, None);
147        assert!(change.moved());
148        assert!(!change.commit_outgoing, "there was nothing to commit");
149        assert!(!change.clear_undo);
150    }
151
152    /// Re-focusing the holder is a success that disturbs nothing — which is
153    /// what makes clicking twice inside a field cheap.
154    #[test]
155    fn refocusing_the_holder_changes_nothing() {
156        let mut session = FormSession::new();
157        set(&mut session, widget(0, 1));
158        let change = set(&mut session, widget(0, 1));
159
160        assert!(!change.moved());
161        assert!(!change.commit_outgoing, "no commit on a re-focus");
162        assert!(!change.clear_undo, "and the history survives");
163        assert_eq!(session.focus, Some(widget(0, 1)));
164    }
165
166    /// Moving to another field commits the old one and drops its history.
167    #[test]
168    fn moving_between_fields_commits_and_clears() {
169        let mut session = FormSession::new();
170        set(&mut session, widget(0, 1));
171        let change = set(&mut session, widget(1, 2));
172
173        assert!(change.moved());
174        assert!(change.commit_outgoing);
175        assert!(change.clear_undo);
176        assert_eq!(change.from, Some(widget(0, 1)));
177    }
178
179    /// Two widgets of the *same* field keep its history: the caret moved, the
180    /// field did not.
181    #[test]
182    fn moving_between_widgets_of_one_field_keeps_the_history() {
183        let mut session = FormSession::new();
184        set(&mut session, widget(0, 1));
185        let change = set(&mut session, widget(0, 5));
186
187        assert!(change.moved());
188        assert!(
189            !change.clear_undo,
190            "the field is the same, so its history stands"
191        );
192    }
193
194    #[test]
195    fn killing_focus_commits_the_outgoing_field() {
196        let mut session = FormSession::new();
197        set(&mut session, widget(0, 1));
198        let change = kill(&mut session);
199
200        assert_eq!(session.focus, None);
201        assert_eq!(change.from, Some(widget(0, 1)));
202        assert_eq!(change.to, None);
203        assert!(change.commit_outgoing);
204        assert!(change.clear_undo);
205    }
206
207    /// Dropping focus twice is harmless, which is what makes clicking an
208    /// empty spot repeatedly idempotent.
209    #[test]
210    fn killing_focus_twice_is_harmless() {
211        let mut session = FormSession::new();
212        set(&mut session, widget(0, 1));
213        kill(&mut session);
214        let second = kill(&mut session);
215
216        assert!(!second.moved());
217        assert!(!second.commit_outgoing);
218        assert_eq!(session.focus, None);
219    }
220
221    /// A non-widget annotation holds focus but has nothing to commit.
222    #[test]
223    fn a_plain_annotation_holds_focus_without_a_value() {
224        let mut session = FormSession::new();
225        let change = set(&mut session, FocusTarget::Annot(AnnotId::new(0, 4)));
226
227        assert_eq!(session.focus, Some(FocusTarget::Annot(AnnotId::new(0, 4))));
228        assert!(!change.commit_outgoing);
229
230        let away = kill(&mut session);
231        assert!(
232            !away.commit_outgoing,
233            "a link has no value to store on the way out"
234        );
235    }
236
237    /// Leaving a field for a plain annotation still commits the field.
238    #[test]
239    fn leaving_a_field_for_an_annotation_commits_it() {
240        let mut session = FormSession::new();
241        set(&mut session, widget(0, 1));
242        let change = set(&mut session, FocusTarget::Annot(AnnotId::new(0, 4)));
243
244        assert!(change.commit_outgoing);
245        assert!(change.clear_undo);
246    }
247
248    /// The asymmetry that makes a right-click outside a focused field leave
249    /// it focused, while a left click on the same point does not.
250    #[test]
251    fn only_the_left_button_drops_focus_on_a_miss() {
252        assert!(miss_drops_focus(Button::Left));
253        assert!(!miss_drops_focus(Button::Right));
254    }
255
256    /// A focus change ends any drag in progress.
257    #[test]
258    fn a_focus_change_ends_a_drag() {
259        use crate::edit::Place;
260        use crate::session::DragAnchor;
261
262        let mut session = FormSession::new();
263        set(&mut session, widget(0, 1));
264        session.drag = Some(DragAnchor {
265            field: FieldId(0),
266            start: Place::start(),
267        });
268
269        set(&mut session, widget(1, 2));
270        assert!(session.drag.is_none());
271    }
272}