From c5e812f9b9102caa03dffc5b3bdad95330f08a1f Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Sun, 26 Jul 2026 10:22:14 +0200 Subject: [PATCH] Scroll the focused preference into view --- src/app.rs | 132 +++++++++++++++++++++++++++++++++++- src/app/view.rs | 2 +- src/app/view/preferences.rs | 1 + 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/app.rs b/src/app.rs index b3d1788..8bcba00 100644 --- a/src/app.rs +++ b/src/app.rs @@ -451,8 +451,10 @@ impl App { self.error = None; } } - Message::FocusNext => return iced::widget::focus_next(), - Message::FocusPrevious => return iced::widget::focus_previous(), + Message::FocusNext => return iced::widget::focus_next().chain(reveal_focused()), + Message::FocusPrevious => { + return iced::widget::focus_previous().chain(reveal_focused()); + } Message::ShowChat => self.detail_tab = DetailTab::Chat, Message::ShowStats => { self.detail_tab = DetailTab::Stats; @@ -1241,6 +1243,111 @@ pub(super) fn composer_id() -> text_input::Id { text_input::Id::new("chat-composer") } +pub(super) fn preferences_scroll_id() -> scrollable::Id { + scrollable::Id::new("preferences-fields") +} + +/// Scrolls the preferences form so the field that just took focus is inside +/// the viewport. Iced moves focus without touching the scroll offset, so a tab +/// past the fold would otherwise send the typing to a field nobody can see. +/// +/// The focus callback carries no bounds, so the position is taken from the +/// innermost container around the focused widget — the row that holds the +/// label and its input. +fn reveal_focused() -> Task { + use iced::advanced::widget::{Id, Operation, operation}; + use iced::{Rectangle, Vector}; + + struct Locate { + rows: Vec, + focused: Option, + } + + impl Operation for Locate { + fn container( + &mut self, + _id: Option<&Id>, + bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + self.rows.push(bounds); + operate_on_children(self); + self.rows.pop(); + } + + fn focusable(&mut self, state: &mut dyn operation::Focusable, _id: Option<&Id>) { + if state.is_focused() { + self.focused = self.rows.last().copied(); + } + } + + fn finish(&self) -> operation::Outcome { + self.focused.map_or(operation::Outcome::None, |field| { + operation::Outcome::Chain(Box::new(Reveal { field })) + }) + } + } + + struct Reveal { + field: Rectangle, + } + + impl Operation for Reveal { + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + operate_on_children(self); + } + + fn scrollable( + &mut self, + state: &mut dyn operation::Scrollable, + id: Option<&Id>, + bounds: Rectangle, + content_bounds: Rectangle, + _translation: Vector, + ) { + if id != Some(&Id::from(preferences_scroll_id())) { + return; + } + let Some(offset) = reveal_offset(self.field, bounds) else { + return; + }; + state.scroll_by( + scrollable::AbsoluteOffset { x: 0.0, y: offset }, + bounds, + content_bounds, + ); + } + } + + iced::advanced::widget::operate(Locate { + rows: Vec::new(), + focused: None, + }) +} + +/// How far the scroll area has to move for `field` to sit fully inside +/// `viewport`, or `None` when it already does. A field taller than the +/// viewport lines up with its top edge. +fn reveal_offset(field: iced::Rectangle, viewport: iced::Rectangle) -> Option { + // Keep a row's worth of margin, so a revealed field never sits flush + // against the edge of the viewport. + const MARGIN: f32 = 12.0; + let above = field.y - MARGIN - viewport.y; + let below = field.y + field.height + MARGIN - (viewport.y + viewport.height); + if above < 0.0 { + Some(above) + } else if below > 0.0 { + Some(below) + } else { + None + } +} + fn focus_composer() -> Task { text_input::focus(composer_id()) } @@ -1343,6 +1450,27 @@ mod tests { assert!(!ModelChoice::Glm52.supports_dspark()); } + #[test] + fn tab_scrolls_a_field_back_into_the_preferences_viewport() { + let viewport = iced::Rectangle { + x: 0.0, + y: 100.0, + width: 700.0, + height: 400.0, + }; + let field = |y| iced::Rectangle { + x: 0.0, + y, + width: 700.0, + height: 30.0, + }; + assert_eq!(reveal_offset(field(200.0), viewport), None); + // Above the fold: scroll back by the gap plus the margin. + assert_eq!(reveal_offset(field(80.0), viewport), Some(-32.0)); + // Below it: the bottom edge plus the margin comes into view. + assert_eq!(reveal_offset(field(490.0), viewport), Some(32.0)); + } + #[test] fn ds4_gib_and_streaming_cache_inputs_are_typed() { assert_eq!( diff --git a/src/app/view.rs b/src/app/view.rs index 672bd53..7332d5c 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -7,7 +7,7 @@ use model_manager::{download_status_bar, format_bytes, format_duration}; use super::{ ActiveDownload, App, DetailTab, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, Message, MetricsPoint, - ModelDownload, ModelOperation, chat_scroll_id, composer_id, models_path, + ModelDownload, ModelOperation, chat_scroll_id, composer_id, models_path, preferences_scroll_id, }; use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::model::{ diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index 9cffc83..581e1e5 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -563,6 +563,7 @@ impl App { column![ header, scrollable(container(fields).padding(iced::Padding::ZERO.right(18))) + .id(preferences_scroll_id()) .height(Length::Fill), footer ]