Scroll the focused preference into view
This commit is contained in:
132
src/app.rs
132
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<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!(
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user