Compare commits
4 Commits
f2133d561b
...
1dcadeb882
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dcadeb882 | ||
|
|
c5e812f9b9 | ||
|
|
671724949f | ||
|
|
a0c3a72f4e |
170
src/app.rs
170
src/app.rs
@@ -133,6 +133,10 @@ pub(crate) enum Message {
|
||||
WindowOpened(window::Id),
|
||||
WindowClosed(window::Id),
|
||||
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),
|
||||
PreferenceDsparkChanged(bool),
|
||||
PreferenceTimeoutChanged(String),
|
||||
@@ -447,6 +451,10 @@ impl App {
|
||||
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::ShowStats => {
|
||||
self.detail_tab = DetailTab::Stats;
|
||||
@@ -1005,6 +1013,18 @@ impl App {
|
||||
pub(crate) fn subscription(&self) -> Subscription<Message> {
|
||||
let mut subscriptions = vec![
|
||||
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_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() => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1230,6 +1254,116 @@ 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, 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> {
|
||||
text_input::focus(composer_id())
|
||||
}
|
||||
@@ -1318,11 +1452,45 @@ mod tests {
|
||||
keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT,
|
||||
);
|
||||
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::DeepSeekV4Pro.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]
|
||||
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::{
|
||||
@@ -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> {
|
||||
let mut body = row![].width(Length::Fill).height(Length::Fill);
|
||||
if !self.config.interface.sidebar_collapsed {
|
||||
|
||||
@@ -131,12 +131,32 @@ impl App {
|
||||
);
|
||||
}
|
||||
}
|
||||
let composer = text_input("Ask DS4Server anything…", &self.composer)
|
||||
.id(composer_id())
|
||||
.on_input(Message::ComposerChanged)
|
||||
.on_submit(Message::SubmitPrompt)
|
||||
// Tab walks every focusable widget of every window, so a composer
|
||||
// left behind an open dialog would take a turn in that dialog's
|
||||
// field order. Behind a modal it becomes a plain look-alike that
|
||||
// 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)
|
||||
.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 {
|
||||
action_button(text("Stop").size(12)).on_press(Message::StopGeneration)
|
||||
} else if self.composer.trim().is_empty() {
|
||||
|
||||
@@ -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