rust_widgets 2.8.4

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! Keyboard-aware layout manager — shifts content upward when the mobile keyboard appears,
//! preventing the focused input from being obscured.
use super::{Layout, LayoutContext};
use crate::compat::{fmt, Any, Box, Vec};
use crate::core::{ObjectId, Rect};
use crate::style::animation::EasingFunction;

/// A layout wrapper that shifts its children upward by the keyboard offset.
///
/// This is useful on mobile platforms where the virtual keyboard
/// occludes the bottom portion of the screen. When `set_keyboard_offset`
/// is called with the keyboard height, all child positions are adjusted
/// so that the content remains visible.
///
/// # Why the offset is animated here
///
/// `animation_duration` used to be stored and never read: the offset was applied in one
/// step, so the documented animation did not exist. The slide is now computed by
/// [`KeyboardAwareLayout::tick`], which a host drives with the frame delta the same way it
/// drives every other animated control (`tick(delta_ms) -> bool`, see
/// [`crate::style::animation::CursordBlink`](crate::style::animation::CURSOR_BLINK_HALF_PERIOD_MS))
/// — a duration of zero keeps the old instantaneous behaviour, which is what a layout built
/// with `new(inner, 0)` still gets.
pub struct KeyboardAwareLayout {
    /// Inner layout that performs the actual child positioning.
    inner: Box<dyn Layout>,
    /// Target keyboard height in pixels. 0 = keyboard hidden.
    keyboard_offset: i32,
    /// Duration of the offset animation in milliseconds.
    animation_duration: u64,
    /// Offset actually applied to the children, interpolated toward
    /// [`Self::keyboard_offset`] by [`Self::tick`].
    current_offset: i32,
    /// The offset the current in-flight transition started from. Saving this fixes the curve
    /// against frame slicing: every tick evaluates the *same* transition from its fixed start,
    /// so equal total time always produces an equal value (S-54).
    transition_start: i32,
    /// Milliseconds of the in-flight transition, or `None` when settled.
    elapsed_ms: Option<u64>,
}

impl fmt::Debug for KeyboardAwareLayout {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("KeyboardAwareLayout")
            .field("keyboard_offset", &self.keyboard_offset)
            .field("current_offset", &self.current_offset)
            .field("transition_start", &self.transition_start)
            .field("animation_duration", &self.animation_duration)
            .field("inner", &"<dyn Layout>")
            .finish()
    }
}

impl KeyboardAwareLayout {
    /// Create a keyboard-aware layout wrapping an inner layout.
    ///
    /// * `inner` – The layout that manages child positions.
    /// * `animation_duration` – Animation duration in milliseconds. `0` applies the offset
    ///   immediately, which is the behaviour a host with no frame loop wants.
    pub fn new(inner: Box<dyn Layout>, animation_duration: u64) -> Self {
        Self {
            inner,
            keyboard_offset: 0,
            animation_duration,
            current_offset: 0,
            transition_start: 0,
            elapsed_ms: None,
        }
    }

    /// Set the target keyboard offset (height of the visible keyboard) and begin the
    /// transition toward it.
    ///
    /// Pass `0` to indicate the keyboard is hidden. With a zero
    /// [`animation_duration`](Self::animation_duration) the offset takes effect immediately;
    /// otherwise it is interpolated by [`Self::tick`] until it arrives.
    pub fn set_keyboard_offset(&mut self, offset: i32) {
        if offset == self.keyboard_offset {
            return;
        }
        self.keyboard_offset = offset;
        if self.animation_duration == 0 {
            self.current_offset = offset;
            self.elapsed_ms = None;
        } else {
            // Restart from *wherever we are now*, not from the previous target: a keyboard
            // that is dismissed mid-slide must reverse smoothly rather than jump back. The fixed
            // start is captured here so the eased progress always interpolates this one segment.
            self.transition_start = self.current_offset;
            self.elapsed_ms = Some(0);
        }
    }

    /// Returns the **target** keyboard offset.
    pub fn keyboard_offset(&self) -> i32 {
        self.keyboard_offset
    }

    /// Returns the offset currently applied to the children.
    ///
    /// This is the target once the transition has settled, and something between the two
    /// while it runs.
    pub fn current_offset(&self) -> i32 {
        self.current_offset
    }

    /// Advances the keyboard slide by `delta_ms`.
    ///
    /// Returns `true` while another frame is still needed, matching the
    /// `tick(delta_ms) -> bool` contract every animated control in this crate follows. With a
    /// zero duration there is nothing to advance and this is always `false`.
    pub fn tick(&mut self, delta_ms: u32) -> bool {
        let Some(elapsed) = self.elapsed_ms else {
            return false;
        };
        if self.animation_duration == 0 {
            self.current_offset = self.keyboard_offset;
            self.elapsed_ms = None;
            return false;
        }
        let elapsed = elapsed.saturating_add(u64::from(delta_ms));
        let progress = (elapsed as f32 / self.animation_duration as f32).min(1.0);
        let eased = EasingFunction::EaseInOut.apply(progress);
        // The `from` is the fixed per-transition start, not `current_offset`. Using the current
        // offset re-eased the whole transition from a moving origin, so the value depended on how
        // the host sliced the same total time into frames (S-54).
        let from = self.transition_start as f32;
        let to = self.keyboard_offset as f32;
        self.current_offset = (from + (to - from) * eased).round() as i32;
        if progress >= 1.0 {
            self.current_offset = self.keyboard_offset;
            self.elapsed_ms = None;
            false
        } else {
            self.elapsed_ms = Some(elapsed);
            true
        }
    }

    /// Returns the animation duration in milliseconds.
    pub fn animation_duration(&self) -> u64 {
        self.animation_duration
    }

    /// Sets the animation duration.
    ///
    /// Setting it to `0` snaps any in-flight transition to its target, so a host that turns
    /// animation off does not leave the content mid-slide.
    pub fn set_animation_duration(&mut self, duration: u64) {
        self.animation_duration = duration;
        if duration == 0 {
            self.current_offset = self.keyboard_offset;
            self.elapsed_ms = None;
        }
    }

    /// Whether a transition is still in flight.
    pub fn is_animating(&self) -> bool {
        self.elapsed_ms.is_some()
    }

    /// Returns a shared reference to the inner layout.
    pub fn inner_layout(&self) -> &dyn Layout {
        self.inner.as_ref()
    }

    /// Returns a mutable reference to the inner layout.
    pub fn inner_layout_mut(&mut self) -> &mut dyn Layout {
        self.inner.as_mut()
    }

    /// Consumes this layout and returns the inner layout.
    pub fn into_inner(self) -> Box<dyn Layout> {
        self.inner
    }
}

impl Layout for KeyboardAwareLayout {
    fn as_any(&self) -> &dyn Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }

    fn add_widget(&mut self, widget_id: ObjectId, stretch: u32) {
        self.inner.add_widget(widget_id, stretch);
    }

    fn remove_widget(&mut self, widget_id: ObjectId) {
        self.inner.remove_widget(widget_id);
    }

    fn child_ids(&self) -> Vec<ObjectId> {
        self.inner.child_ids()
    }

    fn has_child(&self, id: ObjectId) -> bool {
        self.inner.has_child(id)
    }

    fn clear(&mut self) {
        self.inner.clear();
    }

    fn update(&self, rect: Rect, widgets: &mut dyn FnMut(ObjectId, Rect)) {
        // Shift the entire rect upward by the *animated* offset so content
        // remains visible above the keyboard.
        let adjusted = Rect::new(rect.x, rect.y - self.current_offset, rect.width, rect.height);
        self.inner.update(adjusted, widgets);
    }

    fn update_with_context(
        &self,
        rect: Rect,
        context: &LayoutContext,
        widgets: &mut dyn FnMut(ObjectId, Rect),
    ) {
        let adjusted = Rect::new(rect.x, rect.y - self.current_offset, rect.width, rect.height);
        self.inner.update_with_context(adjusted, context, widgets);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compat::HashMap;
    use crate::layout::stack::StackLayout;

    /// Helper to collect update results into a HashMap.
    fn collect_update(layout: &dyn Layout, rect: Rect) -> HashMap<ObjectId, Rect> {
        let mut rects = HashMap::new();
        layout.update(rect, &mut |id, r| {
            rects.insert(id, r);
        });
        rects
    }

    #[test]
    fn keyboard_aware_passes_through_add_remove() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 200);

        layout.add_widget(1, 0);
        layout.add_widget(2, 0);
        assert!(layout.has_child(1));
        assert!(layout.has_child(2));
        assert_eq!(layout.child_ids().len(), 2);

        layout.remove_widget(1);
        assert!(!layout.has_child(1));
        assert!(layout.has_child(2));
    }

    #[test]
    fn keyboard_aware_clear() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 200);

        layout.add_widget(1, 0);
        layout.add_widget(2, 0);
        assert_eq!(layout.child_ids().len(), 2);

        layout.clear();
        assert_eq!(layout.child_ids().len(), 0);
    }

    #[test]
    fn keyboard_aware_no_offset_passthrough() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 200);
        layout.add_widget(42, 0);

        let rects = collect_update(&layout, Rect::new(0, 0, 100, 200));
        // No offset: rect passed through unchanged.
        assert_eq!(rects.get(&42), Some(&Rect::new(0, 0, 100, 200)));
    }

    #[test]
    fn keyboard_aware_offset_shifts_content_up() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        // A zero duration is the host that wants the shift immediately.
        let mut layout = KeyboardAwareLayout::new(inner, 0);
        layout.add_widget(42, 0);
        layout.set_keyboard_offset(150);

        let rects = collect_update(&layout, Rect::new(0, 0, 100, 400));
        // Keyboard is 150px high, so content shifts up by 150:
        // the child receives rect at (0, -150, 100, 400)
        assert_eq!(rects.get(&42), Some(&Rect::new(0, -150, 100, 400)));
    }

    /// With a duration the offset is **interpolated**: the first frame is still at the old
    /// position, the midpoint is halfway, and the transition reports "more work" until it
    /// arrives. `animation_duration` used to be stored and never read — the slide it
    /// documented did not exist.
    #[test]
    fn a_non_zero_duration_slides_the_offset_instead_of_snapping() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 100);
        layout.add_widget(42, 0);
        layout.set_keyboard_offset(100);

        assert!(layout.is_animating(), "setting a target starts a transition");
        assert_eq!(layout.current_offset(), 0, "it starts where it was");

        // Halfway through: the eased value is 50 for `EaseInOut`, so the content has moved
        // half the distance but is not yet at the target.
        assert!(layout.tick(50), "the transition is unfinished");
        assert_eq!(layout.current_offset(), 50);
        let rects = collect_update(&layout, Rect::new(0, 0, 100, 400));
        assert_eq!(rects.get(&42), Some(&Rect::new(0, -50, 100, 400)));

        // Arriving settles it: no further frames are owed, and the offset is exact.
        assert!(!layout.tick(50), "the transition is finished");
        assert_eq!(layout.current_offset(), 100);
        assert!(!layout.is_animating());
        let rects = collect_update(&layout, Rect::new(0, 0, 100, 400));
        assert_eq!(rects.get(&42), Some(&Rect::new(0, -100, 100, 400)));
    }

    /// A keyboard dismissed mid-slide reverses from **wherever the content is now**, so the
    /// motion stays continuous instead of jumping back to the top and starting again.
    #[test]
    fn a_reversed_slide_resumes_from_the_current_position() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 100);
        layout.set_keyboard_offset(100);
        layout.tick(50);
        assert_eq!(layout.current_offset(), 50);

        layout.set_keyboard_offset(0);
        assert_eq!(layout.current_offset(), 50, "no jump on reversal");
        layout.tick(50);
        assert_eq!(layout.current_offset(), 25, "it walks back from where it was");
        layout.tick(50);
        assert_eq!(layout.current_offset(), 0);
    }

    /// S-54: the curve must not depend on how the same total time is sliced into frames.
    ///
    /// The old body used `current_offset` as the `from` of each tick while `eased` ran over
    /// the whole transition, so two slices re-eased a moving origin and landed somewhere else.
    #[test]
    fn equal_total_time_produces_an_equal_offset_regardless_of_slicing() {
        let mut one = KeyboardAwareLayout::new(Box::new(StackLayout::new()), 1000);
        let mut two = KeyboardAwareLayout::new(Box::new(StackLayout::new()), 1000);
        one.set_keyboard_offset(100);
        two.set_keyboard_offset(100);

        one.tick(500);
        let single = one.current_offset();

        two.tick(250);
        two.tick(250);
        let sliced = two.current_offset();

        assert_eq!(single, sliced, "the same total time must yield the same value");
    }

    /// Turning animation off snaps an in-flight transition, so a host that disables motion
    /// does not leave the content parked mid-slide.
    #[test]
    fn disabling_the_duration_settles_an_in_flight_slide() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 100);
        layout.set_keyboard_offset(80);
        layout.tick(10);
        assert!(layout.is_animating());

        layout.set_animation_duration(0);
        assert!(!layout.is_animating(), "the transition was resolved, not dropped");
        assert_eq!(layout.current_offset(), 80);
    }

    /// Setting the same offset again must not restart the transition.
    #[test]
    fn setting_the_same_offset_does_not_restart_the_slide() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 100);
        layout.set_keyboard_offset(60);
        layout.tick(99);
        let nearly_there = layout.current_offset();
        layout.set_keyboard_offset(60);
        assert_eq!(layout.current_offset(), nearly_there, "the progress was preserved");
        assert!(!layout.tick(1), "it still arrives on the same frame");
    }

    #[test]
    fn keyboard_aware_zero_offset_after_set() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 0);
        layout.add_widget(42, 0);
        layout.set_keyboard_offset(200);
        layout.set_keyboard_offset(0); // keyboard hidden again

        let rects = collect_update(&layout, Rect::new(0, 0, 100, 200));
        assert_eq!(rects.get(&42), Some(&Rect::new(0, 0, 100, 200)));
    }

    #[test]
    fn keyboard_aware_getters() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let layout = KeyboardAwareLayout::new(inner, 300);

        assert_eq!(layout.animation_duration(), 300);
        assert_eq!(layout.keyboard_offset(), 0);
        assert_eq!(layout.current_offset(), 0);
        assert!(!layout.is_animating());
    }

    #[test]
    fn keyboard_aware_update_with_context_applies_offset() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 0);
        layout.add_widget(7, 0);
        layout.set_keyboard_offset(100);

        let context = LayoutContext::default();
        let mut rects = HashMap::new();
        layout.update_with_context(Rect::new(0, 0, 200, 300), &context, &mut |id, rect| {
            rects.insert(id, rect);
        });

        // Offset applied: y becomes -100
        assert_eq!(rects.get(&7), Some(&Rect::new(0, -100, 200, 300)));
    }

    #[test]
    fn keyboard_aware_inner_layout_access() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let layout = KeyboardAwareLayout::new(inner, 200);

        let inner = layout.inner_layout();
        assert!(inner.as_any().is::<StackLayout>());
    }

    #[test]
    fn keyboard_aware_inner_layout_mut_access() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let mut layout = KeyboardAwareLayout::new(inner, 200);

        let inner_mut = layout.inner_layout_mut();
        inner_mut.add_widget(99, 0);
        assert!(layout.has_child(99));
    }

    #[test]
    fn keyboard_aware_into_inner() {
        let inner: Box<dyn Layout> = Box::new(StackLayout::new());
        let layout = KeyboardAwareLayout::new(inner, 200);
        let _inner = layout.into_inner();
    }
}