Scroll the focused preference into view

This commit is contained in:
Georg Bauer
2026-07-26 10:22:14 +02:00
parent 671724949f
commit c5e812f9b9
3 changed files with 132 additions and 3 deletions

View File

@@ -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<Message> {
use iced::advanced::widget::{Id, Operation, operation};
use iced::{Rectangle, Vector};
struct Locate {
rows: Vec<Rectangle>,
focused: Option<Rectangle>,
}
impl<T> Operation<T> for Locate {
fn container(
&mut self,
_id: Option<&Id>,
bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
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<T> {
self.focused.map_or(operation::Outcome::None, |field| {
operation::Outcome::Chain(Box::new(Reveal { field }))
})
}
}
struct Reveal {
field: Rectangle,
}
impl<T> Operation<T> for Reveal {
fn container(
&mut self,
_id: Option<&Id>,
_bounds: Rectangle,
operate_on_children: &mut dyn FnMut(&mut dyn Operation<T>),
) {
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<f32> {
// 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<Message> {
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!(

View File

@@ -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::{

View File

@@ -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
]