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),
|
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!(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user