use crate::app::input::handlers::{self, ScrollableTag};
use crate::core::event::MouseEvent;
use crate::core::node::{NodeKind, NodeTree};
use crate::widgets::internal::{ScrollAction, scroll_action_from_mouse_n};
pub(crate) fn handle_scroll_wheel_n(
tree: &mut NodeTree,
event: MouseEvent,
scroll_ticks: usize,
fallback_multiplier: u16,
) -> bool {
let Some(hit) = tree.hit_test(event.x as i16, event.y as i16) else {
return false;
};
let mut cur = Some(hit);
while let Some(id) = cur {
if !tree.is_valid(id) {
break;
}
let tag = handlers::classify_scrollable(&tree.node(id).kind);
let parent = tree.node(id).parent;
let remapped = !event.mods.shift
&& handlers::scroll_view::wheel_remaps_to_horizontal(&tree.node(id).kind);
let scroll_lines = effective_scroll_lines(
&tree.node(id).kind,
scroll_ticks,
fallback_multiplier,
event.mods.shift || remapped,
);
let Some(action) = scroll_action_from_mouse_n(event, scroll_lines) else {
return false;
};
let action = if remapped {
to_horizontal_action(action)
} else {
action
};
let parent_integrated_v_edge = if tag == ScrollableTag::TextArea {
tree.parent_frame_integrated_v_edge(id).unwrap_or(false)
} else {
false
};
let handled = match tag {
ScrollableTag::List => handlers::list_table::handle_list_scroll(tree, id, action),
ScrollableTag::Table => handlers::list_table::handle_table_scroll(tree, id, action),
ScrollableTag::ScrollView if remapped => {
handlers::scroll_view::handle_remapped_wheel_scroll(tree, id, action)
}
ScrollableTag::ScrollView => handlers::scroll_view::handle_scroll(tree, id, action),
ScrollableTag::TextArea => {
handlers::text_area::handle_scroll(tree, id, action, parent_integrated_v_edge)
}
ScrollableTag::HexArea => handlers::hex_area::handle_scroll(tree, id, action),
#[cfg(feature = "terminal")]
ScrollableTag::Terminal => handlers::terminal::handle_scroll(tree, id, action),
ScrollableTag::DraggableTabBar => {
handlers::tabs::handle_tab_bar_scroll(tree, id, action)
}
ScrollableTag::DocumentView => handlers::document_view::handle_scroll(tree, id, action),
ScrollableTag::PanView => handlers::pan_view::handle_scroll(tree, id, action),
ScrollableTag::NonScrollable => false,
};
if handled {
return true;
}
cur = parent;
}
false
}
fn to_horizontal_action(action: ScrollAction) -> ScrollAction {
match action {
ScrollAction::LineUp(lines) => ScrollAction::LineLeft(lines),
ScrollAction::LineDown(lines) => ScrollAction::LineRight(lines),
other => other,
}
}
fn effective_scroll_lines(
kind: &NodeKind,
scroll_ticks: usize,
fallback_multiplier: u16,
horizontal: bool,
) -> usize {
let multiplier = match kind {
NodeKind::ScrollView(node) if horizontal => node
.h_scroll_wheel_multiplier
.or(node.scroll_wheel_multiplier)
.unwrap_or(fallback_multiplier),
NodeKind::ScrollView(node) => node.scroll_wheel_multiplier.unwrap_or(fallback_multiplier),
NodeKind::TextArea(node) => node.scroll_wheel_multiplier.unwrap_or(fallback_multiplier),
NodeKind::DocumentView(node) => node.scroll_wheel_multiplier.unwrap_or(fallback_multiplier),
NodeKind::PanView(_) => 1,
_ => fallback_multiplier,
};
scroll_ticks.saturating_mul(usize::from(multiplier.max(1)))
}
#[cfg(test)]
mod tests {
use super::handle_scroll_wheel_n;
use crate::Length;
use crate::core::element::IntoElement;
use crate::core::event::{KeyMods, MouseEvent, MouseKind};
use crate::core::node::{NodeId, NodeKind, NodeTree};
use crate::layout::LayoutEngine;
use crate::style::Rect;
use crate::widgets::{HStack, ScrollAxis, ScrollView, Text, VStack};
fn nested_tree() -> NodeTree {
let strip = ScrollView::new()
.axis(ScrollAxis::Horizontal)
.height(Length::Px(1))
.child(
HStack::new()
.child(Text::new("wide horizontal strip content that overflows"))
.height(Length::Px(1))
.width(Length::Auto)
.key("strip-row"),
);
let column = (0..20).fold(VStack::new().child(strip), |column, i| {
column.child(
Text::new(format!("row {i}"))
.height(Length::Px(1))
.key(format!("row-{i}")),
)
});
let root: crate::Element = ScrollView::new().child(column).into();
let mut tree = NodeTree::new();
LayoutEngine::reconcile_with_focus(
&mut tree,
&root,
Rect {
x: 0,
y: 0,
w: 20,
h: 8,
},
None,
);
tree
}
fn wheel_down(x: u16, y: u16) -> MouseEvent {
MouseEvent {
x,
y,
kind: MouseKind::ScrollDown,
mods: KeyMods::default(),
}
}
fn scroll_views(tree: &NodeTree) -> Vec<NodeId> {
fn walk(tree: &NodeTree, id: NodeId, out: &mut Vec<NodeId>) {
if matches!(tree.node(id).kind, NodeKind::ScrollView(_)) {
out.push(id);
}
for child in tree.node(id).children.clone() {
walk(tree, child, out);
}
}
let mut out = Vec::new();
walk(tree, tree.root, &mut out);
out
}
fn offsets(tree: &NodeTree, index: usize) -> (usize, usize) {
let id = scroll_views(tree)[index];
let NodeKind::ScrollView(sv) = &tree.node(id).kind else {
panic!("expected scroll view");
};
(sv.offset, sv.h_offset)
}
const OUTER: usize = 0;
const STRIP: usize = 1;
#[test]
fn plain_wheel_pans_horizontal_only_child() {
let mut tree = nested_tree();
assert!(handle_scroll_wheel_n(&mut tree, wheel_down(2, 0), 1, 1));
let (_, h) = offsets(&tree, STRIP);
assert!(h > 0, "inner strip should have panned right");
assert_eq!(offsets(&tree, OUTER).0, 0, "outer must not scroll");
}
#[test]
fn exhausted_horizontal_child_bubbles_to_vertical_ancestor() {
let mut tree = nested_tree();
for _ in 0..200 {
if !handle_scroll_wheel_n(&mut tree, wheel_down(2, 0), 1, 1) {
break;
}
if offsets(&tree, OUTER).0 > 0 {
break;
}
}
let (outer, _) = offsets(&tree, OUTER);
assert!(
outer > 0,
"wheel should bubble to the vertical ancestor once the strip is exhausted"
);
}
}