fix(status-area): forward SNI scroll events (#1431)
StatusNotifierItems can expose a Scroll(int delta, string orientation) method for tray hosts to call when the user wheels over an item. The status-area applet handled activation and context menus, but did not forward wheel events to that method. Track the hovered tray item only while the pointer is over an icon, ignore wheel events already captured by widgets, accumulate fine-grained wheel deltas into discrete scroll steps, and call Scroll on the item proxy. Add unit coverage for the delta/orientation mapping and pixel accumulation. Signed-off-by: William Fish <william.michael.fish@gmail.com>
This commit is contained in:
parent
0441c67b10
commit
fddf3d569b
2 changed files with 109 additions and 7 deletions
|
|
@ -9,11 +9,14 @@ use cosmic::{
|
||||||
},
|
},
|
||||||
cctk::sctk::reexports::calloop,
|
cctk::sctk::reexports::calloop,
|
||||||
iced::{
|
iced::{
|
||||||
self, Length, Subscription,
|
self,
|
||||||
|
Event::Mouse,
|
||||||
|
Length, Subscription, event,
|
||||||
|
mouse::{self, ScrollDelta},
|
||||||
platform_specific::shell::commands::popup::{destroy_popup, get_popup},
|
platform_specific::shell::commands::popup::{destroy_popup, get_popup},
|
||||||
theme::Style,
|
|
||||||
window,
|
window,
|
||||||
},
|
},
|
||||||
|
scroll::{DiscreteScrollDelta, DiscreteScrollState},
|
||||||
surface,
|
surface,
|
||||||
widget::{container, mouse_area},
|
widget::{container, mouse_area},
|
||||||
};
|
};
|
||||||
|
|
@ -33,7 +36,8 @@ pub enum Msg {
|
||||||
StatusMenu((usize, status_menu::Msg)),
|
StatusMenu((usize, status_menu::Msg)),
|
||||||
StatusNotifier(status_notifier_watcher::Event),
|
StatusNotifier(status_notifier_watcher::Event),
|
||||||
TogglePopup(usize),
|
TogglePopup(usize),
|
||||||
Hovered(usize),
|
Hovered(Option<usize>),
|
||||||
|
WheelScrolled(ScrollDelta),
|
||||||
Surface(surface::Action),
|
Surface(surface::Action),
|
||||||
ToggleOverflow,
|
ToggleOverflow,
|
||||||
HoveredOverflow,
|
HoveredOverflow,
|
||||||
|
|
@ -49,6 +53,8 @@ pub(crate) struct App {
|
||||||
max_menu_id: usize,
|
max_menu_id: usize,
|
||||||
popup: Option<window::Id>,
|
popup: Option<window::Id>,
|
||||||
overflow_popup: Option<window::Id>,
|
overflow_popup: Option<window::Id>,
|
||||||
|
hovered_menu: Option<usize>,
|
||||||
|
scroll_states: BTreeMap<usize, DiscreteScrollState>,
|
||||||
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
|
token_tx: Option<calloop::channel::Sender<TokenRequest>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,9 +109,10 @@ impl App {
|
||||||
let overflow_index = self.overflow_index().unwrap_or(0);
|
let overflow_index = self.overflow_index().unwrap_or(0);
|
||||||
let children = self.menus.iter().skip(overflow_index).map(|(id, menu)| {
|
let children = self.menus.iter().skip(overflow_index).map(|(id, menu)| {
|
||||||
mouse_area(
|
mouse_area(
|
||||||
menu_icon_button(&self.core.applet, &menu).on_press_down(Msg::TogglePopup(*id)),
|
menu_icon_button(&self.core.applet, menu).on_press_down(Msg::TogglePopup(*id)),
|
||||||
)
|
)
|
||||||
.on_enter(Msg::Hovered(*id))
|
.on_enter(Msg::Hovered(Some(*id)))
|
||||||
|
.on_exit(Msg::Hovered(None))
|
||||||
.into()
|
.into()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -210,6 +217,7 @@ impl cosmic::Application for App {
|
||||||
{
|
{
|
||||||
let id = *id;
|
let id = *id;
|
||||||
self.menus.remove(&id);
|
self.menus.remove(&id);
|
||||||
|
self.scroll_states.remove(&id);
|
||||||
if self.open_menu == Some(id) {
|
if self.open_menu == Some(id) {
|
||||||
self.open_menu = None;
|
self.open_menu = None;
|
||||||
if let Some(popup_id) = self.popup {
|
if let Some(popup_id) = self.popup {
|
||||||
|
|
@ -312,6 +320,10 @@ impl cosmic::Application for App {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Msg::Hovered(id) => {
|
Msg::Hovered(id) => {
|
||||||
|
self.hovered_menu = id;
|
||||||
|
let Some(id) = id else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
let mut cmds = Vec::new();
|
let mut cmds = Vec::new();
|
||||||
if let Some(old_id) = self.open_menu.take() {
|
if let Some(old_id) = self.open_menu.take() {
|
||||||
if old_id != id {
|
if old_id != id {
|
||||||
|
|
@ -361,6 +373,19 @@ impl cosmic::Application for App {
|
||||||
cmds.push(get_popup(popup_settings));
|
cmds.push(get_popup(popup_settings));
|
||||||
Task::batch(cmds)
|
Task::batch(cmds)
|
||||||
}
|
}
|
||||||
|
Msg::WheelScrolled(delta) => {
|
||||||
|
let Some(id) = self.hovered_menu else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let discrete_delta = self.scroll_states.entry(id).or_default().update(delta);
|
||||||
|
let Some((delta, orientation)) = discrete_scroll_delta(discrete_delta) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
let Some(menu) = self.menus.get(&id) else {
|
||||||
|
return Task::none();
|
||||||
|
};
|
||||||
|
scroll(id, menu.item.item_proxy().clone(), delta, orientation)
|
||||||
|
}
|
||||||
Msg::Surface(a) => {
|
Msg::Surface(a) => {
|
||||||
return cosmic::task::message(cosmic::Action::Cosmic(
|
return cosmic::task::message(cosmic::Action::Cosmic(
|
||||||
cosmic::app::Action::Surface(a),
|
cosmic::app::Action::Surface(a),
|
||||||
|
|
@ -405,6 +430,7 @@ impl cosmic::Application for App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Msg::HoveredOverflow => {
|
Msg::HoveredOverflow => {
|
||||||
|
self.hovered_menu = None;
|
||||||
let mut cmds = Vec::new();
|
let mut cmds = Vec::new();
|
||||||
if self.overflow_popup.is_some() {
|
if self.overflow_popup.is_some() {
|
||||||
// If we already have an overflow popup, do nothing
|
// If we already have an overflow popup, do nothing
|
||||||
|
|
@ -460,6 +486,12 @@ impl cosmic::Application for App {
|
||||||
subscriptions.push(menu.subscription(is_open).with(*id).map(Msg::StatusMenu));
|
subscriptions.push(menu.subscription(is_open).with(*id).map(Msg::StatusMenu));
|
||||||
}
|
}
|
||||||
subscriptions.push(activation_token_subscription(0).map(Msg::Token));
|
subscriptions.push(activation_token_subscription(0).map(Msg::Token));
|
||||||
|
subscriptions.push(event::listen_with(|e, status, _| match (e, status) {
|
||||||
|
(Mouse(mouse::Event::WheelScrolled { delta }), event::Status::Ignored) => {
|
||||||
|
Some(Msg::WheelScrolled(delta))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}));
|
||||||
|
|
||||||
iced::Subscription::batch(subscriptions)
|
iced::Subscription::batch(subscriptions)
|
||||||
}
|
}
|
||||||
|
|
@ -472,9 +504,10 @@ impl cosmic::Application for App {
|
||||||
.iter()
|
.iter()
|
||||||
.take(overflow_index.unwrap_or(self.menus.len()))
|
.take(overflow_index.unwrap_or(self.menus.len()))
|
||||||
.map(|(id, menu)| {
|
.map(|(id, menu)| {
|
||||||
mouse_area(menu_icon_button(&self.core.applet, &menu).on_press(Msg::Activate(*id)))
|
mouse_area(menu_icon_button(&self.core.applet, menu).on_press(Msg::Activate(*id)))
|
||||||
.on_right_press(Msg::TogglePopup(*id))
|
.on_right_press(Msg::TogglePopup(*id))
|
||||||
.on_enter(Msg::Hovered(*id))
|
.on_enter(Msg::Hovered(Some(*id)))
|
||||||
|
.on_exit(Msg::Hovered(None))
|
||||||
.into()
|
.into()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -570,6 +603,33 @@ fn activate(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scroll(
|
||||||
|
id: usize,
|
||||||
|
item_proxy: crate::subscriptions::status_notifier_item::StatusNotifierItemProxy<'static>,
|
||||||
|
delta: i32,
|
||||||
|
orientation: &'static str,
|
||||||
|
) -> Task<cosmic::Action<Msg>> {
|
||||||
|
Task::future(async move {
|
||||||
|
match item_proxy.scroll(delta, orientation).await {
|
||||||
|
Ok(_) => cosmic::action::app(Msg::None),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!("Scroll failed for {}: {}", id, err);
|
||||||
|
cosmic::action::app(Msg::None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discrete_scroll_delta(delta: DiscreteScrollDelta) -> Option<(i32, &'static str)> {
|
||||||
|
if delta.y != 0 {
|
||||||
|
Some((delta.y as i32, "vertical"))
|
||||||
|
} else if delta.x != 0 {
|
||||||
|
Some((delta.x as i32, "horizontal"))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn menu_icon_button<'a>(
|
fn menu_icon_button<'a>(
|
||||||
applet: &'a cosmic::applet::Context,
|
applet: &'a cosmic::applet::Context,
|
||||||
menu: &'a status_menu::State,
|
menu: &'a status_menu::State,
|
||||||
|
|
@ -614,3 +674,43 @@ fn menu_icon_button<'a>(
|
||||||
pub fn main() -> iced::Result {
|
pub fn main() -> iced::Result {
|
||||||
cosmic::applet::run::<App>(())
|
cosmic::applet::run::<App>(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discrete_scroll_prefers_vertical_delta() {
|
||||||
|
assert_eq!(
|
||||||
|
discrete_scroll_delta(cosmic::scroll::DiscreteScrollDelta { x: 4, y: -2 }),
|
||||||
|
Some((-2, "vertical"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discrete_scroll_uses_horizontal_delta_when_vertical_is_zero() {
|
||||||
|
assert_eq!(
|
||||||
|
discrete_scroll_delta(cosmic::scroll::DiscreteScrollDelta { x: 3, y: 0 }),
|
||||||
|
Some((3, "horizontal"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discrete_scroll_ignores_zero_delta() {
|
||||||
|
assert_eq!(
|
||||||
|
discrete_scroll_delta(cosmic::scroll::DiscreteScrollDelta { x: 0, y: 0 }),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pixel_scroll_accumulates_before_emitting_discrete_delta() {
|
||||||
|
let mut state = cosmic::scroll::DiscreteScrollState::default();
|
||||||
|
|
||||||
|
let first = state.update(ScrollDelta::Pixels { x: 0.0, y: 12.0 });
|
||||||
|
assert_eq!(discrete_scroll_delta(first), None);
|
||||||
|
|
||||||
|
let second = state.update(ScrollDelta::Pixels { x: 0.0, y: 12.0 });
|
||||||
|
assert_eq!(discrete_scroll_delta(second), Some((1, "vertical")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,8 @@ pub trait StatusNotifierItem {
|
||||||
fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
|
fn activate(&self, x: i32, y: i32) -> zbus::Result<()>;
|
||||||
|
|
||||||
fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>;
|
fn secondary_activate(&self, x: i32, y: i32) -> zbus::Result<()>;
|
||||||
|
|
||||||
|
fn scroll(&self, delta: i32, orientation: &str) -> zbus::Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue