diff --git a/src/app.rs b/src/app.rs index 6fa96e7..c9b8fbc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3762,6 +3762,23 @@ pub(crate) mod test_utils { ); } + pub fn assert_zoom_affects_item_size( + tab: &mut Tab, + message: tab::Message, + should_zoom: bool, + ) { + let grid_icon_size = tab.config.icon_sizes.grid; + let list_icon_size = tab.config.icon_sizes.list; + + debug!("Emitting {:?}", message); + tab.update(message, Modifiers::empty()); + + let grid_size_changed = grid_icon_size != tab.config.icon_sizes.grid; + let list_size_changed = list_icon_size != tab.config.icon_sizes.list; + + assert_eq!(grid_size_changed || list_size_changed, should_zoom); + } + /// Assert that tab's items are equal to a path's entries. pub fn assert_eq_tab_path_contents(tab: &Tab, path: &Path) { let Location::Path(ref tab_path) = tab.location else { diff --git a/src/mouse_area.rs b/src/mouse_area.rs index 71a049e..2de7aa7 100644 --- a/src/mouse_area.rs +++ b/src/mouse_area.rs @@ -6,8 +6,14 @@ use cosmic::{ iced_core::{ border::Border, event::{self, Event}, + keyboard::{ + self, + key::{self, Key}, + Event::{KeyPressed, KeyReleased}, + Modifiers, + }, layout, - mouse::{self, click}, + mouse::{self, click, Event as MouseEvent}, overlay, renderer::{self, Quad, Renderer as _}, touch, @@ -39,6 +45,7 @@ pub struct MouseArea<'a, Message> { on_back_release: Option) -> Message + 'a>>, on_forward_press: Option) -> Message + 'a>>, on_forward_release: Option) -> Message + 'a>>, + on_scroll: Option Option + 'a>>, show_drag_rect: bool, } @@ -144,6 +151,16 @@ impl<'a, Message> MouseArea<'a, Message> { self } + /// The message to emit on a scroll. + #[must_use] + pub fn on_scroll( + mut self, + message: impl Fn(mouse::ScrollDelta, Modifiers) -> Option + 'a, + ) -> Self { + self.on_scroll = Some(Box::new(message)); + self + } + #[must_use] pub fn show_drag_rect(mut self, show_drag_rect: bool) -> Self { self.show_drag_rect = show_drag_rect; @@ -163,7 +180,7 @@ impl<'a, Message> MouseArea<'a, Message> { struct State { // TODO: Support on_mouse_enter and on_mouse_exit drag_initiated: Option, - + modifiers: Modifiers, prev_click: Option<(mouse::Click, Instant)>, } @@ -227,6 +244,7 @@ impl<'a, Message> MouseArea<'a, Message> { on_back_release: None, on_forward_press: None, on_forward_release: None, + on_scroll: None, show_drag_rect: false, } } @@ -578,6 +596,21 @@ fn update( } } + if let Some(message) = widget.on_scroll.as_ref() { + if let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event { + if let Some(on_scroll) = widget.on_scroll.as_ref() { + if let Some(message) = on_scroll(delta.clone(), state.modifiers) { + shell.publish(message); + return event::Status::Captured; + } + } + } + } + + if let Event::Keyboard(key_event) = event { + handle_key_event(key_event, state) + }; + if let Some((message, drag_rect)) = widget.on_drag.as_ref().zip(state.drag_rect(cursor)) { shell.publish(message(drag_rect.intersection(&layout_bounds).map( |mut rect| { @@ -590,3 +623,53 @@ fn update( event::Status::Ignored } + +fn handle_key_event(key_event: &keyboard::Event, state: &mut State) { + if let KeyPressed { + key: Key::Named(key::Named::Control), + .. + } = key_event + { + state.modifiers.insert(Modifiers::CTRL); + } + + if let KeyReleased { + key: Key::Named(key::Named::Control), + .. + } = key_event + { + state.modifiers.remove(Modifiers::CTRL); + } + + if let KeyPressed { + key: Key::Named(key::Named::Shift), + .. + } = key_event + { + state.modifiers.insert(Modifiers::SHIFT); + } + + if let KeyReleased { + key: Key::Named(key::Named::Shift), + .. + } = key_event + { + state.modifiers.remove(Modifiers::SHIFT); + } + + if let KeyPressed { + key: Key::Named(key::Named::Alt), + .. + } = key_event + { + state.modifiers.insert(Modifiers::ALT); + } + + if let KeyReleased { + key: Key::Named(key::Named::Alt), + .. + } = key_event + { + state.modifiers.remove(Modifiers::ALT); + } +} diff --git a/src/tab.rs b/src/tab.rs index 54bbc48..664ff79 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -7,8 +7,9 @@ use cosmic::{ }, alignment::{Horizontal, Vertical}, clipboard::dnd::DndAction, + event, futures::SinkExt, - keyboard::Modifiers, + keyboard::{self, Modifiers}, subscription::{self, Subscription}, //TODO: export in cosmic::widget widget::{ @@ -24,7 +25,7 @@ use cosmic::{ Rectangle, Size, }, - iced_core::widget::tree, + iced_core::{mouse::ScrollDelta, widget::tree}, iced_style::rule, theme, widget::{ @@ -3394,7 +3395,8 @@ impl Tab { .on_press(move |_point_opt| Message::Click(None)) .on_release(|_| Message::ClickRelease(None)) .on_back_press(move |_point_opt| Message::GoPrevious) - .on_forward_press(move |_point_opt| Message::GoNext); + .on_forward_press(move |_point_opt| Message::GoNext) + .on_scroll(respond_to_scroll_direction); if self.context_menu.is_some() { mouse_area = mouse_area.on_right_press(move |_point_opt| Message::ContextMenu(None)); @@ -3402,6 +3404,7 @@ impl Tab { mouse_area = mouse_area.on_right_press(Message::ContextMenu); } + let should_propogate_events = true; let mut popover = widget::popover(mouse_area); if let Some(point) = self.context_menu { @@ -3607,20 +3610,42 @@ impl Tab { } } +pub fn respond_to_scroll_direction(delta: ScrollDelta, modifiers: Modifiers) -> Option { + if !modifiers.control() { + return None; + } + + let delta_y = match delta { + ScrollDelta::Lines { y, .. } => y, + ScrollDelta::Pixels { y, .. } => y, + }; + + if delta_y > 0.0 { + return Some(Message::ZoomIn); + } + + if delta_y < 0.0 { + return Some(Message::ZoomOut); + } + + None +} + #[cfg(test)] mod tests { use std::{fs, io, path::PathBuf}; - use cosmic::iced_runtime::keyboard::Modifiers; + use cosmic::{iced::mouse::ScrollDelta, iced_runtime::keyboard::Modifiers}; use log::{debug, trace}; use tempfile::TempDir; use test_log::test; - use super::{scan_path, Location, Message, Tab}; + use super::{scan_path, respond_to_scroll_direction, Location, Message, Tab}; use crate::{ app::test_utils::{ - assert_eq_tab_path, empty_fs, eq_path_item, filter_dirs, read_dir_sorted, simple_fs, - tab_click_new, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, + assert_eq_tab_path, assert_zoom_affects_item_size, empty_fs, eq_path_item, + filter_dirs, read_dir_sorted, simple_fs, tab_click_new, NAME_LEN, NUM_DIRS, NUM_FILES, + NUM_HIDDEN, NUM_NESTED, }, config::{IconSizes, TabConfig}, }; @@ -3853,6 +3878,58 @@ mod tests { Ok(()) } + #[test] + fn tab_zoom_in_increases_item_view_size() -> io::Result<()> { + let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?; + let path = fs.path(); + + let mut tab = Tab::new(Location::Path(path.into()), TabConfig::default()); + + let should_affect_size = true; + assert_zoom_affects_item_size(&mut tab, Message::ZoomIn, should_affect_size); + Ok(()) + } + + fn tab_zoom_out_decreases_item_view_size() -> io::Result<()> { + let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?; + let path = fs.path(); + + let mut tab = Tab::new(Location::Path(path.into()), TabConfig::default()); + + let should_affect_size = true; + assert_zoom_affects_item_size(&mut tab, Message::ZoomOut, should_affect_size); + Ok(()) + } + + #[test] + fn tab_scroll_up_with_ctrl_modifier_zooms() -> io::Result<()> { + let message_maybe = respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::CTRL); + assert!(!message_maybe.is_none()); + assert!(matches!(message_maybe.unwrap(), Message::ZoomIn)); + Ok(()) + } + + #[test] + fn tab_scroll_up_without_ctrl_modifier_does_not_zoom() -> io::Result<()> { + let message_maybe = respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::empty()); + assert!(message_maybe.is_none()); + Ok(()) + } + + #[test] + fn tab_scroll_down_with_ctrl_modifier_zooms() -> io::Result<()> { + let message_maybe = respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: -1.0 }, Modifiers::CTRL); + assert!(!message_maybe.is_none()); + assert!(matches!(message_maybe.unwrap(), Message::ZoomOut)); + Ok(()) + } + + #[test] + fn tab_scroll_down_without_ctrl_modifier_does_not_zoom() -> io::Result<()> { + let message_maybe = respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: -1.0 }, Modifiers::empty()); + assert!(message_maybe.is_none()); + Ok(()) + } #[test] fn tab_empty_history_does_nothing_on_prev_next() -> io::Result<()> { let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?; @@ -4000,7 +4077,7 @@ impl Widget for ArcElementWrapper { _clipboard: &mut dyn cosmic::iced_core::Clipboard, _shell: &mut cosmic::iced_core::Shell<'_, M>, _viewport: &Rectangle, - ) -> cosmic::iced_core::event::Status { + ) -> event::Status { self.0.lock().unwrap().as_widget_mut().on_event( _state, _event, _layout, _cursor, _renderer, _clipboard, _shell, _viewport, )