Compare commits

...

4 Commits

Author SHA1 Message Date
Georg Bauer
1dcadeb882 Scroll to the focused preference from its unscrolled position 2026-07-26 10:27:04 +02:00
Georg Bauer
c5e812f9b9 Scroll the focused preference into view 2026-07-26 10:22:14 +02:00
Georg Bauer
671724949f Keep the composer out of a dialog's field order 2026-07-26 10:11:40 +02:00
Georg Bauer
a0c3a72f4e Move focus between form fields with tab 2026-07-26 10:07:39 +02:00
4 changed files with 205 additions and 7 deletions

View File

@@ -133,6 +133,10 @@ pub(crate) enum Message {
WindowOpened(window::Id), WindowOpened(window::Id),
WindowClosed(window::Id), WindowClosed(window::Id),
DismissPanel, DismissPanel,
/// Tab and shift-tab: iced leaves the key to the application, so the fields
/// of a form are only linked once we move the focus ourselves.
FocusNext,
FocusPrevious,
PreferenceModelChanged(ModelChoice), PreferenceModelChanged(ModelChoice),
PreferenceDsparkChanged(bool), PreferenceDsparkChanged(bool),
PreferenceTimeoutChanged(String), PreferenceTimeoutChanged(String),
@@ -447,6 +451,10 @@ impl App {
self.error = None; self.error = None;
} }
} }
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::ShowChat => self.detail_tab = DetailTab::Chat,
Message::ShowStats => { Message::ShowStats => {
self.detail_tab = DetailTab::Stats; self.detail_tab = DetailTab::Stats;
@@ -1005,6 +1013,18 @@ impl App {
pub(crate) fn subscription(&self) -> Subscription<Message> { pub(crate) fn subscription(&self) -> Subscription<Message> {
let mut subscriptions = vec![ let mut subscriptions = vec![
keyboard::on_key_press(shortcut), keyboard::on_key_press(shortcut),
// A focused text field takes escape for itself to drop its own
// focus, so a dialog would never see it through `on_key_press`.
iced::event::listen_with(|event, _, _| {
matches!(
event,
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
)
.then_some(Message::DismissPanel)
}),
window::close_requests().map(Message::WindowClosed), window::close_requests().map(Message::WindowClosed),
window::close_events().map(Message::WindowClosed), window::close_events().map(Message::WindowClosed),
]; ];
@@ -1199,7 +1219,11 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Messag
keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => { keyboard::Key::Character("m") if modifiers.command() && modifiers.shift() => {
Some(Message::OpenModelManager) Some(Message::OpenModelManager)
} }
keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel), keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() {
Message::FocusPrevious
} else {
Message::FocusNext
}),
_ => None, _ => None,
} }
} }
@@ -1230,6 +1254,116 @@ pub(super) fn composer_id() -> text_input::Id {
text_input::Id::new("chat-composer") 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, translation.y) 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.
///
/// Layout places the content in its own unscrolled coordinates, so `field` only
/// says where it sits once `scrolled` — the offset the area is already at — is
/// taken off it.
fn reveal_offset(field: iced::Rectangle, viewport: iced::Rectangle, scrolled: f32) -> 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 top = field.y - scrolled;
let above = top - MARGIN - viewport.y;
let below = top + 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> { fn focus_composer() -> Task<Message> {
text_input::focus(composer_id()) text_input::focus(composer_id())
} }
@@ -1318,11 +1452,45 @@ mod tests {
keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT, keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT,
); );
assert!(matches!(message, Some(Message::OpenModelManager))); assert!(matches!(message, Some(Message::OpenModelManager)));
let tab = keyboard::Key::Named(keyboard::key::Named::Tab);
assert!(matches!(
shortcut(tab.clone(), keyboard::Modifiers::empty()),
Some(Message::FocusNext)
));
assert!(matches!(
shortcut(tab, keyboard::Modifiers::SHIFT),
Some(Message::FocusPrevious)
));
assert!(ModelChoice::DeepSeekV4Flash.supports_dspark()); assert!(ModelChoice::DeepSeekV4Flash.supports_dspark());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark()); assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark());
assert!(!ModelChoice::Glm52.supports_dspark()); 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, 0.0), None);
// Above the fold: scroll back by the gap plus the margin.
assert_eq!(reveal_offset(field(80.0), viewport, 0.0), Some(-32.0));
// Below it: the bottom edge plus the margin comes into view.
assert_eq!(reveal_offset(field(490.0), viewport, 0.0), Some(32.0));
// A field the scroll already brought into view stays put, and one
// further down moves by the gap alone, not by the whole scroll.
assert_eq!(reveal_offset(field(690.0), viewport, 500.0), None);
assert_eq!(reveal_offset(field(990.0), viewport, 500.0), Some(32.0));
}
#[test] #[test]
fn ds4_gib_and_streaming_cache_inputs_are_typed() { fn ds4_gib_and_streaming_cache_inputs_are_typed() {
assert_eq!( assert_eq!(

View File

@@ -7,7 +7,7 @@ use model_manager::{download_status_bar, format_bytes, format_duration};
use super::{ use super::{
ActiveDownload, App, DetailTab, MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, Message, MetricsPoint, 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::database::{ProjectWithSessions, Session, SessionState};
use crate::model::{ use crate::model::{
@@ -62,6 +62,15 @@ impl App {
} }
} }
/// Whether a dialog covers the window. Focus moves through the whole widget
/// tree, so the layers below have to stay out of the dialog's field order.
pub(super) fn modal_open(&self) -> bool {
self.preferences_open
|| self.pending_project_path.is_some()
|| self.session_rename.is_some()
|| self.menu_session().is_some()
}
fn main_view(&self) -> Element<'_, Message> { fn main_view(&self) -> Element<'_, Message> {
let mut body = row![].width(Length::Fill).height(Length::Fill); let mut body = row![].width(Length::Fill).height(Length::Fill);
if !self.config.interface.sidebar_collapsed { if !self.config.interface.sidebar_collapsed {

View File

@@ -131,12 +131,32 @@ impl App {
); );
} }
} }
let composer = text_input("Ask DS4Server anything…", &self.composer) // Tab walks every focusable widget of every window, so a composer
.id(composer_id()) // left behind an open dialog would take a turn in that dialog's
.on_input(Message::ComposerChanged) // field order. Behind a modal it becomes a plain look-alike that
.on_submit(Message::SubmitPrompt) // cannot be focused; the modal dims it either way.
let composer: Element<'_, Message> = if self.modal_open() {
container(
text(if self.composer.is_empty() {
"Ask DS4Server anything…"
} else {
&self.composer
})
.size(14)
.color(muted_text()),
)
.padding(12) .padding(12)
.size(14); .width(Length::Fill)
.into()
} else {
text_input("Ask DS4Server anything…", &self.composer)
.id(composer_id())
.on_input(Message::ComposerChanged)
.on_submit(Message::SubmitPrompt)
.padding(12)
.size(14)
.into()
};
let action = if self.generating { let action = if self.generating {
action_button(text("Stop").size(12)).on_press(Message::StopGeneration) action_button(text("Stop").size(12)).on_press(Message::StopGeneration)
} else if self.composer.trim().is_empty() { } else if self.composer.trim().is_empty() {

View File

@@ -563,6 +563,7 @@ impl App {
column![ column![
header, header,
scrollable(container(fields).padding(iced::Padding::ZERO.right(18))) scrollable(container(fields).padding(iced::Padding::ZERO.right(18)))
.id(preferences_scroll_id())
.height(Length::Fill), .height(Length::Fill),
footer footer
] ]