Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ web-sys = { version = "0.3.81", features = [
'WebGlTexture',
'WebGlUniformLocation',
'WebGlVertexArrayObject',
'WheelEvent',
'Window',
] }
compact_str = "0.9.0"
Expand Down
21 changes: 20 additions & 1 deletion examples/minimal/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ fn main() -> io::Result<()> {
let mouse_position = Rc::new(RefCell::new((0, 0)));
let mouse_button = Rc::new(RefCell::new(None::<MouseButton>));
let mouse_event_kind = Rc::new(RefCell::new(None::<MouseEventKind>));
let scroll_count = Rc::new(RefCell::new(0i32));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe track scrolled and h&v separately, otherwise it can give the impression that ratzilla does not distinguish between the two.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is h&v?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, horizontal and vertical


let terminal = MultiBackendBuilder::with_fallback(BackendType::Dom)
.webgl2_options(WebGl2BackendOptions::new()
Expand Down Expand Up @@ -50,19 +51,37 @@ fn main() -> io::Result<()> {
}
});

terminal.on_wheel_event({
let scroll_count_cloned = scroll_count.clone();
move |mouse_event| {
let mut scroll_count = scroll_count_cloned.borrow_mut();
match mouse_event.event {
MouseEventKind::ScrolledVertical(delta) => {
*scroll_count += delta.to_steps();
}
MouseEventKind::ScrolledHorizontal(delta) => {
*scroll_count += delta.to_steps();
}
_ => {}
}
}
});

terminal.draw_web(move |f| {
let counter = counter.borrow();
let mouse_position = mouse_position.borrow();
let mouse_button = mouse_button.borrow();
let mouse_event_kind = mouse_event_kind.borrow();
let scroll_count = scroll_count.borrow();

f.render_widget(
Paragraph::new(format!(
"Space pressed: {counter}\n\
MouseX: {:?}\n\
MouseY: {:?}\n\
MouseButton: {mouse_button:?}\n\
MouseEvent: {mouse_event_kind:?}",
MouseEvent: {mouse_event_kind:?}\n\
Scroll wheel steps: {scroll_count}",
mouse_position.0, mouse_position.1
))
.alignment(Alignment::Center)
Expand Down
82 changes: 82 additions & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,40 @@ pub enum MouseButton {
Unidentified,
}

/// Scroll delta with the original delta mode from the browser.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum ScrollDelta {
/// Delta in pixels
Pixels(i32),
/// Delta in lines (typically represents wheel notches)
Lines(i32),
/// Delta in pages
Pages(i32),
}

impl ScrollDelta {
/// DOM_DELTA_PIXEL: The units of measurement for the delta are pixels.
const DOM_DELTA_PIXEL: u32 = 0;
/// DOM_DELTA_LINE: The units of measurement for the delta are individual lines of text.
const DOM_DELTA_LINE: u32 = 1;
/// DOM_DELTA_PAGE: The units of measurement for the delta are pages.
const DOM_DELTA_PAGE: u32 = 2;

/// Normalize the scroll delta to discrete wheel steps/clicks.
///
/// This converts the delta to an approximate number of wheel notches:
/// - Lines: Already represent wheel notches, returned as-is
/// - Pixels: Divided by ~100 pixels per notch
/// - Pages: Multiplied by ~10 notches per page
pub fn to_steps(self) -> i32 {
match self {
ScrollDelta::Pixels(px) => px / 100,
Copy link
Member

@junkdog junkdog Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

px here leads me to believe this is in pixel units, but it's actually converted to rows (or cols) from pixels. (pages is similarly suspect) i can't read today

where did you get 100 from? i think this value could vary across devices. we probably have to measure it if we care about pixels to rows/cols

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its claude suggested and I approximately tried to verify that and "pages" to approximate 10 wheel events to scroll one page view, etc. But yes, I agree, they are arbitrary, I suspected this would be a problem.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i tried with:

document.addEventListener('wheel', (e) => {
    console.log({
      deltaX: e.deltaX,
      deltaY: e.deltaY,
      deltaMode: ['PIXEL', 'LINE', 'PAGE'][e.deltaMode],
      ctrlKey: e.ctrlKey,
      shiftKey: e.shiftKey
    });
  });

depending on how quick i scroll the wheel, on at 27" 1440p screen, runing kde/wayland:

  • firefox: values range between 16-70px,
  • chromium: 16-360(!)

so 100 is definitely too high, esp for ff.

ScrollDelta::Lines(lines) => lines,
ScrollDelta::Pages(pages) => pages * 10,
}
}
}

/// A mouse event.
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum MouseEventKind {
Expand All @@ -151,6 +185,10 @@ pub enum MouseEventKind {
Pressed,
/// Mouse button released
Released,
/// Mouse scrolled vertically (positive = down, negative = up)
ScrolledVertical(ScrollDelta),
/// Mouse scrolled horizontally (positive = right, negative = left)
ScrolledHorizontal(ScrollDelta),
/// Unidentified mouse event
Unidentified,
}
Expand Down Expand Up @@ -205,3 +243,47 @@ impl From<String> for MouseEventKind {
}
}
}

/// Convert a [`web_sys::WheelEvent`] to a [`MouseEvent`].
impl From<web_sys::WheelEvent> for MouseEvent {
fn from(event: web_sys::WheelEvent) -> Self {
let ctrl = event.ctrl_key();
let alt = event.alt_key();
let shift = event.shift_key();
let delta_mode = event.delta_mode();
let delta_x = event.delta_x();
let delta_y = event.delta_y();

// Create ScrollDelta based on the browser's delta mode
let to_scroll_delta = |delta: f64| -> ScrollDelta {
let delta_int = delta as i32;
match delta_mode {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a thought, but the more i think about it: would it maybe be easier if we treated all scroll events as either -1 or +1 (or some other increment)? i don't really see pixel-perfect scrolling translating to TUIs. this would omit the need for resolving actual values behind ScrollDelta::Pages and ::Pixels (+ the need to intercept when said values change)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's how I use it, but thought it better left to the user to decide if they want to use that information or discard it.

ScrollDelta::DOM_DELTA_PIXEL => ScrollDelta::Pixels(delta_int),
ScrollDelta::DOM_DELTA_LINE => ScrollDelta::Lines(delta_int),
ScrollDelta::DOM_DELTA_PAGE => ScrollDelta::Pages(delta_int),
_ => ScrollDelta::Pixels(delta_int), // fallback to pixels
}
};

let scroll_x = to_scroll_delta(delta_x);
let scroll_y = to_scroll_delta(delta_y);

// Determine the event kind based on which delta is larger
// Compare normalized steps to determine primary scroll direction
let event_kind = if scroll_x.to_steps().abs() > scroll_y.to_steps().abs() {
MouseEventKind::ScrolledHorizontal(scroll_x)
} else {
MouseEventKind::ScrolledVertical(scroll_y)
};

MouseEvent {
button: MouseButton::Unidentified,
event: event_kind,
x: event.client_x() as u32,
y: event.client_y() as u32,
ctrl,
alt,
shift,
}
}
}
18 changes: 18 additions & 0 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ pub trait WebRenderer {
closure.forget();
}

/// Handles wheel events.
///
/// This method takes a closure that will be called on every `wheel` event.
fn on_wheel_event<F>(&self, mut callback: F)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to merge on_wheel_event with on_mouse_event? it's one less closure to care about, so the callback function could handle all mouse event kinds in one place.

where
F: FnMut(MouseEvent) + 'static,
{
let closure = Closure::<dyn FnMut(_)>::new(move |event: web_sys::WheelEvent| {
callback(event.into());
});
let window = window().unwrap();
let document = window.document().unwrap();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use get_document() to retrieve it with proper error handling (i know the other on_*_event functions do not, feel free to update them too)

https://github.com/orhun/ratzilla/blob/82d9438e0b7afdedf7f2f980de94a2f6eb207e65/src/backend/utils.rs#L144-L148

document
.add_event_listener_with_callback("wheel", closure.as_ref().unchecked_ref())
.unwrap();
closure.forget();
}

/// Requests an animation frame.
fn request_animation_frame(f: &Closure<dyn FnMut()>) {
window()
Expand Down