diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index b9819ced..57892578 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -815,6 +815,7 @@ impl Impactor { } None }); + let keyboard_subscription = iced::event::listen_with(Self::keyboard_event); Subscription::batch(vec![ device_subscription, @@ -825,9 +826,36 @@ impl Impactor { certificate_reset_subscription, relaunch_subscription, close_subscription, + keyboard_subscription, ]) } + fn keyboard_event( + event: iced::Event, + status: iced::event::Status, + window_id: window::Id, + ) -> Option { + let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab), + modifiers, + repeat, + .. + }) = event + else { + return None; + }; + + (status == iced::event::Status::Ignored + && !repeat + && modifiers + .difference(iced::keyboard::Modifiers::SHIFT) + .is_empty()) + .then_some(Message::LoginWindowMessage( + window_id, + login_window::Message::FocusTraversal(modifiers.shift()), + )) + } + pub fn view(&self, window_id: window::Id) -> Element<'_, Message> { if let Some(login_window) = self.login_windows.get(&window_id) { return login_window diff --git a/apps/plumeimpactor/src/screen/windows/login_window.rs b/apps/plumeimpactor/src/screen/windows/login_window.rs index 7b734420..9ab8b249 100644 --- a/apps/plumeimpactor/src/screen/windows/login_window.rs +++ b/apps/plumeimpactor/src/screen/windows/login_window.rs @@ -1,3 +1,4 @@ +use iced::advanced::widget::{self, operation}; use iced::futures::SinkExt; use iced::widget::{button, column, container, row, text, text_input}; use iced::{Alignment, Element, Fill, Task, window}; @@ -20,6 +21,7 @@ pub enum Message { TwoFactorCodeChanged(String), TwoFactorSubmit, TwoFactorCancel, + FocusTraversal(bool), SendCodeViaSms(u32), RequestTwoFactor { sms: bool, @@ -39,6 +41,7 @@ pub struct LoginWindow { two_factor_is_sms: bool, trusted_phones: Vec<(u32, String)>, two_factor_tx: Option>>, + form_id: widget::Id, } impl LoginWindow { @@ -51,6 +54,13 @@ impl LoginWindow { ..Default::default() }); + let form_id = widget::Id::from(format!("login-window-{id}-form")); + let initial_focus_id = form_id.clone(); + + let task = task + .then(move |_| focus(initial_focus_id.clone(), false)) + .discard(); + ( Self { window_id: Some(id), @@ -64,8 +74,9 @@ impl LoginWindow { two_factor_is_sms: false, trusted_phones: Vec::new(), two_factor_tx: None, + form_id, }, - task.discard(), + task, ) } @@ -110,7 +121,7 @@ impl LoginWindow { self.two_factor_code.clear(); self.login_error = None; self.two_factor_error = None; - Task::none() + focus(self.form_id.clone(), false).discard() } Message::LoginCancel => { if let Some(id) = self.window_id { @@ -156,6 +167,7 @@ impl LoginWindow { self.two_factor_error = None; Task::none() } + Message::FocusTraversal(backwards) => focus(self.form_id.clone(), backwards).discard(), Message::TwoFactorSubmit => { let code = self.two_factor_code.trim().to_string(); if code.is_empty() { @@ -251,7 +263,10 @@ impl LoginWindow { content = content.push(container(text("")).width(Fill)); content = content.push(buttons); - container(content).padding(appearance::THEME_PADDING).into() + container(content) + .id(self.form_id.clone()) + .padding(appearance::THEME_PADDING) + .into() } fn view_two_factor(&self) -> Element<'_, Message> { @@ -325,7 +340,10 @@ impl LoginWindow { .spacing(appearance::THEME_PADDING); content = content.push(buttons); - container(content).padding(20).into() + container(content) + .id(self.form_id.clone()) + .padding(20) + .into() } fn perform_login( @@ -389,3 +407,62 @@ impl LoginWindow { ) } } + +fn focus(form_id: widget::Id, backwards: bool) -> Task<()> { + widget::operate(operation::scope( + form_id, + operation::then( + operation::focusable::count(), + if backwards { + cycle_backwards + } else { + cycle_forwards + }, + ), + )) +} + +struct CycleFocus { + target: usize, + index: usize, +} + +impl CycleFocus { + fn new(target: usize) -> Self { + Self { target, index: 0 } + } +} + +fn cycle_forwards(count: operation::focusable::Count) -> CycleFocus { + CycleFocus::new(count.focused.map_or(0, |index| (index + 1) % count.total)) +} + +fn cycle_backwards(count: operation::focusable::Count) -> CycleFocus { + CycleFocus::new( + count + .focused + .and_then(|index| index.checked_sub(1)) + .unwrap_or(count.total.saturating_sub(1)), + ) +} + +impl widget::Operation for CycleFocus { + fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn widget::Operation)) { + operate(self); + } + + fn focusable( + &mut self, + _id: Option<&widget::Id>, + _bounds: iced::Rectangle, + state: &mut dyn operation::Focusable, + ) { + if self.target == self.index { + state.focus(); + } else { + state.unfocus(); + } + + self.index += 1; + } +}