Skip to main content

tui_lipan/widgets/pan_view/
mod.rs

1//! Two-dimensional pan viewport widget.
2
3pub mod layout;
4pub mod node;
5pub mod reconcile;
6
7pub(crate) use self::layout::measure_pan_view;
8pub use self::node::PanViewNode;
9
10use std::sync::Arc;
11
12use crate::callback::Callback;
13use crate::core::element::{Element, ElementKind, Key};
14use crate::core::event::{KeyCode, KeyEvent};
15use crate::style::Length;
16
17/// Pan viewport metrics for a single axis pair.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub struct PanMetrics {
20    /// Child content width in cells.
21    pub content_w: u16,
22    /// Child content height in cells.
23    pub content_h: u16,
24    /// Viewport width in cells.
25    pub viewport_w: u16,
26    /// Viewport height in cells.
27    pub viewport_h: u16,
28    /// Maximum horizontal offset when clamped.
29    pub max_x: i32,
30    /// Maximum vertical offset when clamped.
31    pub max_y: i32,
32}
33
34/// Event emitted after a `PanView` offset changes.
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
36pub struct PanEvent {
37    /// New horizontal offset. May be negative when clamping is disabled.
38    pub x: i32,
39    /// New vertical offset. May be negative when clamping is disabled.
40    pub y: i32,
41    /// Current content/viewport bounds.
42    pub metrics: PanMetrics,
43}
44
45/// Key bindings for `PanView` keyboard panning.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47pub struct PanKeymap(u8);
48
49impl PanKeymap {
50    /// Disable key handling.
51    pub const NONE: Self = Self(0);
52    /// Arrow keys.
53    pub const ARROWS: Self = Self(1 << 0);
54    /// Vim-style h/j/k/l keys.
55    pub const VIM: Self = Self(1 << 1);
56    /// Default key set (arrows and vim keys).
57    pub const DEFAULT: Self = Self(Self::ARROWS.0 | Self::VIM.0);
58
59    /// Check if this keymap includes another set.
60    pub fn contains(self, other: Self) -> bool {
61        (self.0 & other.0) == other.0
62    }
63}
64
65impl std::ops::BitOr for PanKeymap {
66    type Output = Self;
67
68    fn bitor(self, rhs: Self) -> Self {
69        Self(self.0 | rhs.0)
70    }
71}
72
73impl std::ops::BitOrAssign for PanKeymap {
74    fn bitor_assign(&mut self, rhs: Self) {
75        self.0 |= rhs.0;
76    }
77}
78
79impl std::ops::BitAnd for PanKeymap {
80    type Output = Self;
81
82    fn bitand(self, rhs: Self) -> Self {
83        Self(self.0 & rhs.0)
84    }
85}
86
87impl std::ops::BitAndAssign for PanKeymap {
88    fn bitand_assign(&mut self, rhs: Self) {
89        self.0 &= rhs.0;
90    }
91}
92
93impl std::ops::Not for PanKeymap {
94    type Output = Self;
95
96    fn not(self) -> Self {
97        Self(!self.0)
98    }
99}
100
101impl Default for PanKeymap {
102    fn default() -> Self {
103        Self::DEFAULT
104    }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
108pub(crate) enum PanAction {
109    Delta(i16, i16),
110}
111
112fn signed_step(step: u16) -> i16 {
113    step.min(i16::MAX as u16) as i16
114}
115
116pub(crate) fn pan_action_from_key(
117    key: &KeyEvent,
118    keymap: PanKeymap,
119    key_step: (u16, u16),
120) -> Option<PanAction> {
121    let x = signed_step(key_step.0);
122    let y = signed_step(key_step.1);
123    match key.code {
124        KeyCode::Left if keymap.contains(PanKeymap::ARROWS) => Some(PanAction::Delta(-x, 0)),
125        KeyCode::Right if keymap.contains(PanKeymap::ARROWS) => Some(PanAction::Delta(x, 0)),
126        KeyCode::Up if keymap.contains(PanKeymap::ARROWS) => Some(PanAction::Delta(0, -y)),
127        KeyCode::Down if keymap.contains(PanKeymap::ARROWS) => Some(PanAction::Delta(0, y)),
128        KeyCode::Char('h') if keymap.contains(PanKeymap::VIM) => Some(PanAction::Delta(-x, 0)),
129        KeyCode::Char('l') if keymap.contains(PanKeymap::VIM) => Some(PanAction::Delta(x, 0)),
130        KeyCode::Char('k') if keymap.contains(PanKeymap::VIM) => Some(PanAction::Delta(0, -y)),
131        KeyCode::Char('j') if keymap.contains(PanKeymap::VIM) => Some(PanAction::Delta(0, y)),
132        _ => None,
133    }
134}
135
136pub(crate) fn pan_metrics(
137    content_w: u16,
138    content_h: u16,
139    viewport_w: u16,
140    viewport_h: u16,
141) -> PanMetrics {
142    PanMetrics {
143        content_w,
144        content_h,
145        viewport_w,
146        viewport_h,
147        max_x: i32::from(content_w.saturating_sub(viewport_w)),
148        max_y: i32::from(content_h.saturating_sub(viewport_h)),
149    }
150}
151
152pub(crate) fn clamp_pan_offset((x, y): (i32, i32), metrics: PanMetrics, clamp: bool) -> (i32, i32) {
153    if clamp {
154        (x.clamp(0, metrics.max_x), y.clamp(0, metrics.max_y))
155    } else {
156        (x, y)
157    }
158}
159
160fn free_axis_bounds(content: u16, viewport: u16, margin: u16) -> (i32, i32) {
161    let margin = margin.min(content.max(1)).min(viewport.max(1));
162    (
163        -(i32::from(viewport) - i32::from(margin)),
164        i32::from(content) - i32::from(margin),
165    )
166}
167
168pub(crate) fn bound_pan_offset(
169    (x, y): (i32, i32),
170    metrics: PanMetrics,
171    clamp: bool,
172    free_pan_margin: Option<(u16, u16)>,
173) -> (i32, i32) {
174    if clamp {
175        return clamp_pan_offset((x, y), metrics, true);
176    }
177
178    let Some((margin_x, margin_y)) = free_pan_margin else {
179        return (x, y);
180    };
181
182    let (min_x, max_x) = free_axis_bounds(metrics.content_w, metrics.viewport_w, margin_x);
183    let (min_y, max_y) = free_axis_bounds(metrics.content_h, metrics.viewport_h, margin_y);
184    (x.clamp(min_x, max_x), y.clamp(min_y, max_y))
185}
186
187pub(crate) fn apply_pan_delta(
188    (x, y): (i32, i32),
189    dx: i16,
190    dy: i16,
191    metrics: PanMetrics,
192    clamp: bool,
193    free_pan_margin: Option<(u16, u16)>,
194) -> (i32, i32) {
195    let next_x = x.saturating_add(i32::from(dx));
196    let next_y = y.saturating_add(i32::from(dy));
197    bound_pan_offset((next_x, next_y), metrics, clamp, free_pan_margin)
198}
199
200pub(crate) fn apply_pan_action(
201    offset: (i32, i32),
202    action: PanAction,
203    metrics: PanMetrics,
204    clamp: bool,
205    free_pan_margin: Option<(u16, u16)>,
206) -> (i32, i32) {
207    match action {
208        PanAction::Delta(dx, dy) => {
209            apply_pan_delta(offset, dx, dy, metrics, clamp, free_pan_margin)
210        }
211    }
212}
213
214/// A single-child two-dimensional panning viewport.
215#[derive(Clone)]
216pub struct PanView {
217    pub(crate) width: Length,
218    pub(crate) height: Length,
219    pub(crate) offset: Option<(i32, i32)>,
220    pub(crate) on_pan: Option<Callback<PanEvent>>,
221    pub(crate) clamp: bool,
222    pub(crate) center_content: bool,
223    pub(crate) free_pan_margin: Option<(u16, u16)>,
224    pub(crate) drag_to_pan: bool,
225    pub(crate) wheel_to_pan: bool,
226    pub(crate) keymap: PanKeymap,
227    pub(crate) key_step: (u16, u16),
228    pub(crate) focusable: bool,
229    pub(crate) tab_stop: bool,
230    pub(crate) on_focus: Option<Callback<()>>,
231    pub(crate) on_blur: Option<Callback<()>>,
232    pub(crate) pan_state_key: Option<Key>,
233    pub(crate) child: Option<Box<Element>>,
234}
235
236impl Default for PanView {
237    fn default() -> Self {
238        Self {
239            width: Length::Flex(1),
240            height: Length::Flex(1),
241            offset: None,
242            on_pan: None,
243            clamp: true,
244            center_content: false,
245            free_pan_margin: None,
246            drag_to_pan: true,
247            wheel_to_pan: true,
248            keymap: PanKeymap::default(),
249            key_step: (4, 2),
250            focusable: false,
251            tab_stop: true,
252            on_focus: None,
253            on_blur: None,
254            pan_state_key: None,
255            child: None,
256        }
257    }
258}
259
260impl PanView {
261    /// Create an empty pan view.
262    pub fn new() -> Self {
263        Self::default()
264    }
265
266    /// Set the single child element.
267    pub fn child(mut self, child: impl Into<Element>) -> Self {
268        self.child = Some(Box::new(child.into()));
269        self
270    }
271
272    /// Set requested viewport width.
273    pub fn width(mut self, width: Length) -> Self {
274        self.width = width;
275        self
276    }
277
278    /// Set requested viewport height.
279    pub fn height(mut self, height: Length) -> Self {
280        self.height = height;
281        self
282    }
283
284    /// Set a controlled viewport offset.
285    pub fn offset(mut self, offset: (i32, i32)) -> Self {
286        self.offset = Some(offset);
287        self
288    }
289
290    /// Callback fired when input changes the viewport offset.
291    pub fn on_pan(mut self, cb: Callback<PanEvent>) -> Self {
292        self.on_pan = Some(cb);
293        self
294    }
295
296    /// Enable or disable clamping to content bounds.
297    pub fn clamp(mut self, clamp: bool) -> Self {
298        self.clamp = clamp;
299        self
300    }
301
302    /// Center the child in the viewport until input or a persisted offset takes over.
303    pub fn center_content(mut self, center: bool) -> Self {
304        self.center_content = center;
305        self
306    }
307
308    /// Limit unclamped panning so at least `margin` cells of content remain reachable.
309    ///
310    /// This only applies when `.clamp(false)` is set. It gives free-canvas previews
311    /// room to pull content around without allowing the child to drift infinitely far away.
312    pub fn free_pan_margin(mut self, margin: u16) -> Self {
313        self.free_pan_margin = Some((margin, margin));
314        self
315    }
316
317    /// Set independent horizontal and vertical margins for unclamped panning limits.
318    pub fn free_pan_margins(mut self, margins: (u16, u16)) -> Self {
319        self.free_pan_margin = Some(margins);
320        self
321    }
322
323    /// Enable or disable pointer-drag panning.
324    pub fn drag_to_pan(mut self, enabled: bool) -> Self {
325        self.drag_to_pan = enabled;
326        self
327    }
328
329    /// Enable or disable mouse-wheel panning (default `true`).
330    ///
331    /// The wheel pans vertically and `Shift`+wheel horizontally, each tick
332    /// moving one [`key_step`](Self::key_step) in that axis. Disable this when
333    /// an ancestor should receive the wheel instead.
334    pub fn wheel_to_pan(mut self, enabled: bool) -> Self {
335        self.wheel_to_pan = enabled;
336        self
337    }
338
339    /// Configure keys that move the viewport.
340    pub fn keymap(mut self, keymap: PanKeymap) -> Self {
341        self.keymap = keymap;
342        if keymap != PanKeymap::NONE {
343            self.focusable = true;
344        }
345        self
346    }
347
348    /// Alias matching `ScrollView::scroll_keys` style.
349    pub fn pan_keys(self, keymap: PanKeymap) -> Self {
350        self.keymap(keymap)
351    }
352
353    /// Set keyboard panning step as `(horizontal_cells, vertical_cells)`.
354    ///
355    /// The default is `(4, 2)` because terminal cells are taller than they are wide.
356    pub fn key_step(mut self, step: (u16, u16)) -> Self {
357        self.key_step = (step.0.max(1), step.1.max(1));
358        self
359    }
360
361    /// Allow the pan view to receive focus.
362    pub fn focusable(mut self, focusable: bool) -> Self {
363        self.focusable = focusable;
364        self
365    }
366
367    /// Control whether the pan view participates in sequential focus navigation.
368    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
369        self.tab_stop = tab_stop;
370        self
371    }
372
373    /// Set the callback fired when the pan view receives focus.
374    pub fn on_focus(mut self, cb: Callback<()>) -> Self {
375        self.on_focus = Some(cb);
376        self
377    }
378
379    /// Set the callback fired when the pan view loses focus.
380    pub fn on_blur(mut self, cb: Callback<()>) -> Self {
381        self.on_blur = Some(cb);
382        self
383    }
384
385    /// Set a stable key for persisting uncontrolled pan offset across remounts.
386    pub fn pan_state_key(mut self, key: impl Into<Key>) -> Self {
387        self.pan_state_key = Some(key.into());
388        self
389    }
390
391    /// Convenience overload for callers that already have an `Arc<str>`.
392    pub fn state_key(self, key: impl Into<Arc<str>>) -> Self {
393        self.pan_state_key(Key::from(key.into()))
394    }
395}
396
397impl From<PanView> for Element {
398    fn from(value: PanView) -> Self {
399        Element::new(ElementKind::PanView(value))
400    }
401}
402
403impl crate::layout::hash::LayoutHash for PanView {
404    fn layout_hash(
405        &self,
406        hasher: &mut impl std::hash::Hasher,
407        recurse: &dyn Fn(&Element) -> Option<u64>,
408    ) -> Option<()> {
409        use std::hash::Hash;
410
411        self.width.hash(hasher);
412        self.height.hash(hasher);
413        self.clamp.hash(hasher);
414        self.center_content.hash(hasher);
415        self.free_pan_margin.hash(hasher);
416        self.drag_to_pan.hash(hasher);
417        self.wheel_to_pan.hash(hasher);
418        self.keymap.hash(hasher);
419        self.key_step.hash(hasher);
420        self.focusable.hash(hasher);
421        if let Some(child) = &self.child {
422            recurse(child)?.hash(hasher);
423        }
424        Some(())
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::core::node::{NodeKind, NodeTree};
432    use crate::layout::LayoutEngine;
433    use crate::style::Rect;
434    use crate::widgets::Text;
435
436    fn wide_tall_child() -> Element {
437        Text::new("0123456789\n0123456789\n0123456789").into()
438    }
439
440    fn small_child() -> Element {
441        Text::new("tiny").into()
442    }
443
444    #[test]
445    fn pan_view_clamps_offsets_to_content_bounds() {
446        let metrics = pan_metrics(20, 10, 8, 4);
447
448        assert_eq!(clamp_pan_offset((99, 99), metrics, true), (12, 6));
449        assert_eq!(clamp_pan_offset((99, 99), metrics, false), (99, 99));
450        assert_eq!(clamp_pan_offset((-4, -2), metrics, false), (-4, -2));
451    }
452
453    #[test]
454    fn pan_view_drag_direction_decreases_offset_when_dragging_right_down() {
455        let metrics = pan_metrics(20, 10, 8, 4);
456
457        assert_eq!(apply_pan_delta((5, 3), -2, -1, metrics, true, None), (3, 2));
458        assert_eq!(apply_pan_delta((5, 3), 2, 1, metrics, true, None), (7, 4));
459        assert_eq!(
460            apply_pan_delta((0, 0), -2, -1, metrics, false, None),
461            (-2, -1)
462        );
463    }
464
465    #[test]
466    fn pan_view_free_pan_margin_bounds_unclamped_offsets() {
467        let metrics = pan_metrics(20, 10, 8, 4);
468
469        assert_eq!(
470            bound_pan_offset((-99, -99), metrics, false, Some((1, 1))),
471            (-7, -3)
472        );
473        assert_eq!(
474            bound_pan_offset((99, 99), metrics, false, Some((1, 1))),
475            (19, 9)
476        );
477    }
478
479    #[test]
480    fn pan_view_keymap_matches_arrows_and_vim() {
481        let key = |code| KeyEvent {
482            code,
483            mods: crate::core::event::KeyMods::default(),
484        };
485
486        assert_eq!(
487            pan_action_from_key(&key(KeyCode::Right), PanKeymap::ARROWS, (4, 2)),
488            Some(PanAction::Delta(4, 0))
489        );
490        assert_eq!(
491            pan_action_from_key(&key(KeyCode::Char('h')), PanKeymap::VIM, (4, 2)),
492            Some(PanAction::Delta(-4, 0))
493        );
494        assert_eq!(
495            pan_action_from_key(&key(KeyCode::Char('h')), PanKeymap::ARROWS, (4, 2)),
496            None
497        );
498        assert_eq!(
499            pan_action_from_key(&key(KeyCode::Char('u')), PanKeymap::VIM, (4, 2)),
500            None
501        );
502    }
503
504    #[test]
505    fn pan_view_reconcile_clamps_and_offsets_child_rect() {
506        let root: Element = PanView::new()
507            .width(Length::Px(5))
508            .height(Length::Px(2))
509            .offset((99, 99))
510            .child(wide_tall_child())
511            .into();
512        let mut tree = NodeTree::new();
513        LayoutEngine::reconcile_with_focus(
514            &mut tree,
515            &root,
516            Rect {
517                x: 0,
518                y: 0,
519                w: 5,
520                h: 2,
521            },
522            None,
523        );
524
525        let node = tree.node(tree.root);
526        let NodeKind::PanView(pan) = &node.kind else {
527            panic!("expected PanView root");
528        };
529        assert_eq!((pan.content_w, pan.content_h), (10, 3));
530        assert_eq!((pan.viewport_w, pan.viewport_h), (5, 2));
531        assert_eq!((pan.offset_x, pan.offset_y), (5, 1));
532
533        let child = tree.node(node.children[0]);
534        assert_eq!(child.rect.x, -5);
535        assert_eq!(child.rect.y, -1);
536        assert_eq!(child.rect.w, 10);
537        assert_eq!(child.rect.h, 3);
538    }
539
540    #[test]
541    fn pan_view_keyboard_updates_uncontrolled_offset_and_persists_key() {
542        use std::cell::RefCell;
543        use std::rc::Rc;
544
545        let events = Rc::new(RefCell::new(Vec::new()));
546        let events_cb = events.clone();
547        let root: Element = PanView::new()
548            .width(Length::Px(5))
549            .height(Length::Px(2))
550            .pan_state_key("pan-test")
551            .on_pan(Callback::new(move |event| {
552                events_cb.borrow_mut().push(event);
553            }))
554            .child(wide_tall_child())
555            .into();
556        let mut tree = NodeTree::new();
557        let bounds = Rect {
558            x: 0,
559            y: 0,
560            w: 5,
561            h: 2,
562        };
563        LayoutEngine::reconcile_with_focus(&mut tree, &root, bounds, None);
564
565        let root_id = tree.root;
566        let handled = crate::app::input::handlers::pan_view::handle_key(
567            &mut tree,
568            root_id,
569            &KeyEvent {
570                code: KeyCode::Right,
571                mods: crate::core::event::KeyMods::default(),
572            },
573        );
574        assert!(handled);
575        let NodeKind::PanView(pan) = &tree.node(tree.root).kind else {
576            panic!("expected PanView root");
577        };
578        assert_eq!((pan.offset_x, pan.offset_y), (4, 0));
579        assert_eq!(events.borrow()[0].x, 4);
580
581        LayoutEngine::reconcile_with_focus(&mut tree, &root, bounds, None);
582        let NodeKind::PanView(pan) = &tree.node(tree.root).kind else {
583            panic!("expected PanView root");
584        };
585        assert_eq!((pan.offset_x, pan.offset_y), (4, 0));
586    }
587
588    #[test]
589    fn pan_view_can_center_smaller_content_with_negative_offset() {
590        let root: Element = PanView::new()
591            .width(Length::Px(20))
592            .height(Length::Px(10))
593            .clamp(false)
594            .center_content(true)
595            .child(small_child())
596            .into();
597        let mut tree = NodeTree::new();
598        LayoutEngine::reconcile_with_focus(
599            &mut tree,
600            &root,
601            Rect {
602                x: 0,
603                y: 0,
604                w: 20,
605                h: 10,
606            },
607            None,
608        );
609
610        let node = tree.node(tree.root);
611        let NodeKind::PanView(pan) = &node.kind else {
612            panic!("expected PanView root");
613        };
614        assert_eq!((pan.offset_x, pan.offset_y), (-8, -4));
615        let child = tree.node(node.children[0]);
616        assert_eq!(child.rect.x, 8);
617        assert_eq!(child.rect.y, 4);
618    }
619}