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) keymap: PanKeymap,
226    pub(crate) key_step: (u16, u16),
227    pub(crate) focusable: bool,
228    pub(crate) pan_state_key: Option<Key>,
229    pub(crate) child: Option<Box<Element>>,
230}
231
232impl Default for PanView {
233    fn default() -> Self {
234        Self {
235            width: Length::Flex(1),
236            height: Length::Flex(1),
237            offset: None,
238            on_pan: None,
239            clamp: true,
240            center_content: false,
241            free_pan_margin: None,
242            drag_to_pan: true,
243            keymap: PanKeymap::default(),
244            key_step: (4, 2),
245            focusable: true,
246            pan_state_key: None,
247            child: None,
248        }
249    }
250}
251
252impl PanView {
253    /// Create an empty pan view.
254    pub fn new() -> Self {
255        Self::default()
256    }
257
258    /// Set the single child element.
259    pub fn child(mut self, child: impl Into<Element>) -> Self {
260        self.child = Some(Box::new(child.into()));
261        self
262    }
263
264    /// Set requested viewport width.
265    pub fn width(mut self, width: Length) -> Self {
266        self.width = width;
267        self
268    }
269
270    /// Set requested viewport height.
271    pub fn height(mut self, height: Length) -> Self {
272        self.height = height;
273        self
274    }
275
276    /// Set a controlled viewport offset.
277    pub fn offset(mut self, offset: (i32, i32)) -> Self {
278        self.offset = Some(offset);
279        self
280    }
281
282    /// Callback fired when input changes the viewport offset.
283    pub fn on_pan(mut self, cb: Callback<PanEvent>) -> Self {
284        self.on_pan = Some(cb);
285        self
286    }
287
288    /// Enable or disable clamping to content bounds.
289    pub fn clamp(mut self, clamp: bool) -> Self {
290        self.clamp = clamp;
291        self
292    }
293
294    /// Center the child in the viewport until input or a persisted offset takes over.
295    pub fn center_content(mut self, center: bool) -> Self {
296        self.center_content = center;
297        self
298    }
299
300    /// Limit unclamped panning so at least `margin` cells of content remain reachable.
301    ///
302    /// This only applies when `.clamp(false)` is set. It gives free-canvas previews
303    /// room to pull content around without allowing the child to drift infinitely far away.
304    pub fn free_pan_margin(mut self, margin: u16) -> Self {
305        self.free_pan_margin = Some((margin, margin));
306        self
307    }
308
309    /// Set independent horizontal and vertical margins for unclamped panning limits.
310    pub fn free_pan_margins(mut self, margins: (u16, u16)) -> Self {
311        self.free_pan_margin = Some(margins);
312        self
313    }
314
315    /// Enable or disable pointer-drag panning.
316    pub fn drag_to_pan(mut self, enabled: bool) -> Self {
317        self.drag_to_pan = enabled;
318        self
319    }
320
321    /// Configure keys that move the viewport.
322    pub fn keymap(mut self, keymap: PanKeymap) -> Self {
323        self.keymap = keymap;
324        if keymap != PanKeymap::NONE {
325            self.focusable = true;
326        }
327        self
328    }
329
330    /// Alias matching `ScrollView::scroll_keys` style.
331    pub fn pan_keys(self, keymap: PanKeymap) -> Self {
332        self.keymap(keymap)
333    }
334
335    /// Set keyboard panning step as `(horizontal_cells, vertical_cells)`.
336    ///
337    /// The default is `(4, 2)` because terminal cells are taller than they are wide.
338    pub fn key_step(mut self, step: (u16, u16)) -> Self {
339        self.key_step = (step.0.max(1), step.1.max(1));
340        self
341    }
342
343    /// Allow the pan view to receive focus.
344    pub fn focusable(mut self, focusable: bool) -> Self {
345        self.focusable = focusable;
346        self
347    }
348
349    /// Set a stable key for persisting uncontrolled pan offset across remounts.
350    pub fn pan_state_key(mut self, key: impl Into<Key>) -> Self {
351        self.pan_state_key = Some(key.into());
352        self
353    }
354
355    /// Convenience overload for callers that already have an `Arc<str>`.
356    pub fn state_key(self, key: impl Into<Arc<str>>) -> Self {
357        self.pan_state_key(Key::from(key.into()))
358    }
359}
360
361impl From<PanView> for Element {
362    fn from(value: PanView) -> Self {
363        Element::new(ElementKind::PanView(value))
364    }
365}
366
367impl crate::layout::hash::LayoutHash for PanView {
368    fn layout_hash(
369        &self,
370        hasher: &mut impl std::hash::Hasher,
371        recurse: &dyn Fn(&Element) -> Option<u64>,
372    ) -> Option<()> {
373        use std::hash::Hash;
374
375        self.width.hash(hasher);
376        self.height.hash(hasher);
377        self.clamp.hash(hasher);
378        self.center_content.hash(hasher);
379        self.free_pan_margin.hash(hasher);
380        self.drag_to_pan.hash(hasher);
381        self.keymap.hash(hasher);
382        self.key_step.hash(hasher);
383        self.focusable.hash(hasher);
384        if let Some(child) = &self.child {
385            recurse(child)?.hash(hasher);
386        }
387        Some(())
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::core::node::{NodeKind, NodeTree};
395    use crate::layout::LayoutEngine;
396    use crate::style::Rect;
397    use crate::widgets::Text;
398
399    fn wide_tall_child() -> Element {
400        Text::new("0123456789\n0123456789\n0123456789").into()
401    }
402
403    fn small_child() -> Element {
404        Text::new("tiny").into()
405    }
406
407    #[test]
408    fn pan_view_clamps_offsets_to_content_bounds() {
409        let metrics = pan_metrics(20, 10, 8, 4);
410
411        assert_eq!(clamp_pan_offset((99, 99), metrics, true), (12, 6));
412        assert_eq!(clamp_pan_offset((99, 99), metrics, false), (99, 99));
413        assert_eq!(clamp_pan_offset((-4, -2), metrics, false), (-4, -2));
414    }
415
416    #[test]
417    fn pan_view_drag_direction_decreases_offset_when_dragging_right_down() {
418        let metrics = pan_metrics(20, 10, 8, 4);
419
420        assert_eq!(apply_pan_delta((5, 3), -2, -1, metrics, true, None), (3, 2));
421        assert_eq!(apply_pan_delta((5, 3), 2, 1, metrics, true, None), (7, 4));
422        assert_eq!(
423            apply_pan_delta((0, 0), -2, -1, metrics, false, None),
424            (-2, -1)
425        );
426    }
427
428    #[test]
429    fn pan_view_free_pan_margin_bounds_unclamped_offsets() {
430        let metrics = pan_metrics(20, 10, 8, 4);
431
432        assert_eq!(
433            bound_pan_offset((-99, -99), metrics, false, Some((1, 1))),
434            (-7, -3)
435        );
436        assert_eq!(
437            bound_pan_offset((99, 99), metrics, false, Some((1, 1))),
438            (19, 9)
439        );
440    }
441
442    #[test]
443    fn pan_view_keymap_matches_arrows_and_vim() {
444        let key = |code| KeyEvent {
445            code,
446            mods: crate::core::event::KeyMods::default(),
447        };
448
449        assert_eq!(
450            pan_action_from_key(&key(KeyCode::Right), PanKeymap::ARROWS, (4, 2)),
451            Some(PanAction::Delta(4, 0))
452        );
453        assert_eq!(
454            pan_action_from_key(&key(KeyCode::Char('h')), PanKeymap::VIM, (4, 2)),
455            Some(PanAction::Delta(-4, 0))
456        );
457        assert_eq!(
458            pan_action_from_key(&key(KeyCode::Char('h')), PanKeymap::ARROWS, (4, 2)),
459            None
460        );
461        assert_eq!(
462            pan_action_from_key(&key(KeyCode::Char('u')), PanKeymap::VIM, (4, 2)),
463            None
464        );
465    }
466
467    #[test]
468    fn pan_view_reconcile_clamps_and_offsets_child_rect() {
469        let root: Element = PanView::new()
470            .width(Length::Px(5))
471            .height(Length::Px(2))
472            .offset((99, 99))
473            .child(wide_tall_child())
474            .into();
475        let mut tree = NodeTree::new();
476        LayoutEngine::reconcile_with_focus(
477            &mut tree,
478            &root,
479            Rect {
480                x: 0,
481                y: 0,
482                w: 5,
483                h: 2,
484            },
485            None,
486        );
487
488        let node = tree.node(tree.root);
489        let NodeKind::PanView(pan) = &node.kind else {
490            panic!("expected PanView root");
491        };
492        assert_eq!((pan.content_w, pan.content_h), (10, 3));
493        assert_eq!((pan.viewport_w, pan.viewport_h), (5, 2));
494        assert_eq!((pan.offset_x, pan.offset_y), (5, 1));
495
496        let child = tree.node(node.children[0]);
497        assert_eq!(child.rect.x, -5);
498        assert_eq!(child.rect.y, -1);
499        assert_eq!(child.rect.w, 10);
500        assert_eq!(child.rect.h, 3);
501    }
502
503    #[test]
504    fn pan_view_keyboard_updates_uncontrolled_offset_and_persists_key() {
505        use std::cell::RefCell;
506        use std::rc::Rc;
507
508        let events = Rc::new(RefCell::new(Vec::new()));
509        let events_cb = events.clone();
510        let root: Element = PanView::new()
511            .width(Length::Px(5))
512            .height(Length::Px(2))
513            .pan_state_key("pan-test")
514            .on_pan(Callback::new(move |event| {
515                events_cb.borrow_mut().push(event);
516            }))
517            .child(wide_tall_child())
518            .into();
519        let mut tree = NodeTree::new();
520        let bounds = Rect {
521            x: 0,
522            y: 0,
523            w: 5,
524            h: 2,
525        };
526        LayoutEngine::reconcile_with_focus(&mut tree, &root, bounds, None);
527
528        let root_id = tree.root;
529        let handled = crate::app::input::handlers::pan_view::handle_key(
530            &mut tree,
531            root_id,
532            &KeyEvent {
533                code: KeyCode::Right,
534                mods: crate::core::event::KeyMods::default(),
535            },
536        );
537        assert!(handled);
538        let NodeKind::PanView(pan) = &tree.node(tree.root).kind else {
539            panic!("expected PanView root");
540        };
541        assert_eq!((pan.offset_x, pan.offset_y), (4, 0));
542        assert_eq!(events.borrow()[0].x, 4);
543
544        LayoutEngine::reconcile_with_focus(&mut tree, &root, bounds, None);
545        let NodeKind::PanView(pan) = &tree.node(tree.root).kind else {
546            panic!("expected PanView root");
547        };
548        assert_eq!((pan.offset_x, pan.offset_y), (4, 0));
549    }
550
551    #[test]
552    fn pan_view_can_center_smaller_content_with_negative_offset() {
553        let root: Element = PanView::new()
554            .width(Length::Px(20))
555            .height(Length::Px(10))
556            .clamp(false)
557            .center_content(true)
558            .child(small_child())
559            .into();
560        let mut tree = NodeTree::new();
561        LayoutEngine::reconcile_with_focus(
562            &mut tree,
563            &root,
564            Rect {
565                x: 0,
566                y: 0,
567                w: 20,
568                h: 10,
569            },
570            None,
571        );
572
573        let node = tree.node(tree.root);
574        let NodeKind::PanView(pan) = &node.kind else {
575            panic!("expected PanView root");
576        };
577        assert_eq!((pan.offset_x, pan.offset_y), (-8, -4));
578        let child = tree.node(node.children[0]);
579        assert_eq!(child.rect.x, 8);
580        assert_eq!(child.rect.y, 4);
581    }
582}