diff --git a/src/app.rs b/src/app.rs index badd52c..b3b6f9a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -46,6 +46,7 @@ pub(super) const MAX_SIDEBAR_WIDTH: i32 = 520; pub(crate) struct App { main_window: window::Id, + pub(super) preferences_window: Option, pub(super) model_manager_window: Option, pub(super) help_window: Option, pub(super) pending_model_delete: Option, @@ -59,7 +60,6 @@ pub(crate) struct App { projects: Vec, config: Config, preference_draft: PreferenceDraft, - preferences_open: bool, preference_error: Option, selected_project: Option, selected_session: Option, @@ -153,6 +153,53 @@ pub(super) enum DetailTab { Stats, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PreferenceSection { + Model, + Endpoint, + Generation, + Execution, + Acceleration, + KvCache, + Steering, +} + +impl PreferenceSection { + const ALL: [Self; 7] = [ + Self::Model, + Self::Endpoint, + Self::Generation, + Self::Execution, + Self::Acceleration, + Self::KvCache, + Self::Steering, + ]; + + fn anchor(self) -> &'static str { + match self { + Self::Model => "preferences-model", + Self::Endpoint => "preferences-endpoint", + Self::Generation => "preferences-generation", + Self::Execution => "preferences-execution", + Self::Acceleration => "preferences-acceleration", + Self::KvCache => "preferences-kv-cache", + Self::Steering => "preferences-steering", + } + } + + fn label(self) -> &'static str { + match self { + Self::Model => "Model & lifecycle", + Self::Endpoint => "Local endpoint", + Self::Generation => "Generation", + Self::Execution => "Execution", + Self::Acceleration => "Acceleration & memory", + Self::KvCache => "KV cache", + Self::Steering => "Steering & diagnostics", + } + } +} + #[derive(Clone, Copy, Debug, Default)] pub(super) struct MetricsPoint { pub(super) decode_tokens_per_second: f32, @@ -171,6 +218,9 @@ pub(crate) enum Message { #[cfg(target_os = "macos")] NativeEdit(crate::native_edit::EditCommand), OpenPreferences, + PreferencesOpened(window::Id), + ClosePreferences, + ScrollPreferences(PreferenceSection), OpenModelManager, OpenHelp, HelpOpened(window::Id), @@ -180,6 +230,7 @@ pub(crate) enum Message { ModelManagerOpened(window::Id), WindowOpened(window::Id), WindowClosed(window::Id), + Escape(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. @@ -325,6 +376,7 @@ impl App { spawn_services(&config, Arc::clone(&metrics)); Self { main_window, + preferences_window: None, model_manager_window: None, help_window: None, pending_model_delete: None, @@ -338,7 +390,6 @@ impl App { projects, config, preference_draft, - preferences_open: false, preference_error: None, selected_project: last_project, selected_session: None, @@ -452,6 +503,7 @@ impl App { let startup_error = None::; Self { main_window, + preferences_window: None, model_manager_window: None, help_window: None, pending_model_delete: None, @@ -465,7 +517,6 @@ impl App { projects: Vec::new(), config, preference_draft, - preferences_open: false, preference_error: None, selected_project: None, selected_session: None, @@ -555,7 +606,19 @@ impl App { Message::NativeEdit(command) => { crate::native_edit::queue_command(&self.native_edit_commands, command) } - Message::OpenPreferences => self.open_preferences(), + Message::OpenPreferences => return self.open_preferences(), + Message::PreferencesOpened(id) => { + if self.preferences_window == Some(id) { + return window::gain_focus(id); + } + } + Message::ClosePreferences => { + if let Some(id) = self.preferences_window { + self.preference_error = None; + return window::close(id); + } + } + Message::ScrollPreferences(section) => return scroll_preferences_to(section), Message::OpenModelManager => return self.open_model_manager(), Message::OpenHelp => return self.open_help(), Message::HelpOpened(id) => { @@ -625,10 +688,22 @@ impl App { self.model_manager_window = None; self.pending_model_delete = None; } + if self.preferences_window == Some(id) { + self.preferences_window = None; + self.preference_error = None; + } if self.help_window == Some(id) { self.help_window = None; } } + Message::Escape(id) => { + if self.preferences_window == Some(id) { + return self.update(Message::ClosePreferences); + } + if id == self.main_window { + return self.update(Message::DismissPanel); + } + } Message::DismissPanel => { if self.pending_session_delete.is_some() { self.pending_session_delete = None; @@ -637,9 +712,6 @@ impl App { } else if self.session_rename.is_some() || self.session_menu.is_some() { self.session_rename = None; self.session_menu = None; - } else if self.preferences_open { - self.preferences_open = false; - self.preference_error = None; } else if self.pending_project_path.is_some() { self.pending_project_path = None; self.project_name_input.clear(); @@ -914,7 +986,14 @@ impl App { self.preference_draft.reset(); self.preference_error = None; } - Message::SavePreferences => self.save_preferences(), + Message::SavePreferences => { + self.save_preferences(); + if self.preference_error.is_none() + && let Some(id) = self.preferences_window + { + return window::close(id); + } + } Message::DownloadArtifact(artifact) => { self.start_model_operation(artifact, ModelOperation::Download) } @@ -1509,7 +1588,7 @@ impl App { }), // 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, _, _| { + iced::event::listen_with(|event, _, id| { matches!( event, iced::Event::Keyboard(keyboard::Event::KeyPressed { @@ -1517,7 +1596,7 @@ impl App { .. }) ) - .then_some(Message::DismissPanel) + .then_some(Message::Escape(id)) }), window::close_requests().map(Message::WindowClosed), window::close_events().map(Message::WindowClosed), @@ -1572,7 +1651,9 @@ impl App { } pub(crate) fn title(&self, id: window::Id) -> String { - if self.model_manager_window == Some(id) { + if self.preferences_window == Some(id) { + "Preferences — DS4Server".to_owned() + } else if self.model_manager_window == Some(id) { "Model Manager — DS4Server".to_owned() } else if self.help_window == Some(id) { "Help — DS4Server".to_owned() @@ -1880,6 +1961,10 @@ pub(super) fn preferences_scroll_id() -> iced::widget::Id { iced::widget::Id::new("preferences-fields") } +fn preferences_section_id(section: PreferenceSection) -> iced::widget::Id { + iced::widget::Id::new(section.anchor()) +} + /// 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. @@ -1888,13 +1973,22 @@ pub(super) fn preferences_scroll_id() -> iced::widget::Id { /// innermost container around the focused widget — the row that holds the /// label and its input. fn reveal_focused() -> Task { + reveal_preferences(None) +} + +fn scroll_preferences_to(section: PreferenceSection) -> Task { + reveal_preferences(Some(preferences_section_id(section))) +} + +fn reveal_preferences(target: Option) -> Task { use iced::advanced::widget::{Id, Operation, operation}; use iced::{Rectangle, Vector}; struct Locate { + target: Option, rows: Vec, next_container: Option, - focused: Option, + found: Option, } impl Operation for Locate { @@ -1909,7 +2003,14 @@ fn reveal_focused() -> Task { } } - fn container(&mut self, _id: Option<&Id>, bounds: Rectangle) { + fn container(&mut self, id: Option<&Id>, bounds: Rectangle) { + if self + .target + .as_ref() + .is_some_and(|target| id == Some(target)) + { + self.found = Some(bounds); + } self.next_container = Some(bounds); } @@ -1919,20 +2020,24 @@ fn reveal_focused() -> Task { bounds: Rectangle, state: &mut dyn operation::Focusable, ) { - if state.is_focused() { - self.focused = self.rows.last().copied().or(Some(bounds)); + if self.target.is_none() && state.is_focused() { + self.found = self.rows.last().copied().or(Some(bounds)); } } fn finish(&self) -> operation::Outcome { - self.focused.map_or(operation::Outcome::None, |field| { - operation::Outcome::Chain(Box::new(Reveal { field })) + self.found.map_or(operation::Outcome::None, |field| { + operation::Outcome::Chain(Box::new(Reveal { + field, + align_top: self.target.is_some(), + })) }) } } struct Reveal { field: Rectangle, + align_top: bool, } impl Operation for Reveal { @@ -1951,8 +2056,13 @@ fn reveal_focused() -> Task { if id != Some(&preferences_scroll_id()) { return; } - let Some(offset) = reveal_offset(self.field, bounds, translation.y) else { - return; + let offset = if self.align_top { + section_offset(self.field, bounds, translation.y) + } else { + let Some(offset) = reveal_offset(self.field, bounds, translation.y) else { + return; + }; + offset }; state.scroll_by( scrollable::AbsoluteOffset { x: 0.0, y: offset }, @@ -1963,12 +2073,17 @@ fn reveal_focused() -> Task { } iced::advanced::widget::operate(Locate { + target, rows: Vec::new(), next_container: None, - focused: None, + found: None, }) } +fn section_offset(field: iced::Rectangle, viewport: iced::Rectangle, scrolled: f32) -> f32 { + field.y - scrolled - viewport.y +} + /// 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. @@ -2128,6 +2243,7 @@ mod tests { // 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)); + assert_eq!(section_offset(field(990.0), viewport, 500.0), 390.0); } #[test] diff --git a/src/app/preferences.rs b/src/app/preferences.rs index 879721e..513c2ba 100644 --- a/src/app/preferences.rs +++ b/src/app/preferences.rs @@ -312,13 +312,23 @@ fn update_runtime_config(runtime_config: &RwLock, config: &Config) { } impl App { - pub(super) fn open_preferences(&mut self) { + pub(super) fn open_preferences(&mut self) -> Task { + if let Some(id) = self.preferences_window { + return window::gain_focus(id); + } if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder { - return; + return Task::none(); } self.preference_draft = PreferenceDraft::from_saved(&self.config); self.preference_error = None; - self.preferences_open = true; + let (id, open) = window::open(window::Settings { + size: Size::new(920.0, 700.0), + min_size: Some(Size::new(720.0, 480.0)), + icon: Some(app_icon()), + ..Default::default() + }); + self.preferences_window = Some(id); + open.map(Message::PreferencesOpened) } pub(super) fn save_preferences(&mut self) { @@ -413,7 +423,6 @@ impl App { self._endpoint = Some(endpoint); } self.preference_draft = PreferenceDraft::from_saved(&self.config); - self.preferences_open = false; self.preference_error = None; self.error = None; } diff --git a/src/app/view.rs b/src/app/view.rs index 0968a53..2d7e2ff 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -8,7 +8,8 @@ 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, preferences_scroll_id, + ModelDownload, ModelOperation, PreferenceSection, chat_scroll_id, composer_id, models_path, + preferences_scroll_id, }; use crate::database::{ProjectWithSessions, Session, SessionState}; use crate::model::{ @@ -53,7 +54,9 @@ const TRAFFIC_LIGHT_WIDTH: f32 = 78.0; impl App { pub(crate) fn view(&self, id: window::Id) -> Element<'_, Message> { - let content = if self.model_manager_window == Some(id) { + let content = if self.preferences_window == Some(id) { + self.preferences_panel() + } else if self.model_manager_window == Some(id) { self.model_manager() } else if self.help_window == Some(id) { self.help_view() @@ -98,8 +101,7 @@ 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.pending_project_path.is_some() || self.pending_session_delete.is_some() || self.session_rename.is_some() || self.menu_session().is_some() @@ -161,8 +163,6 @@ impl App { #[cfg(target_os = "macos")] if let Some((prompt, _)) = &self.pending_tool_approval { layers.push(self.tool_approval_panel(prompt)); - } else if self.preferences_open { - layers.push(self.preferences_panel()); } else if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); } else if let Some((_, title)) = &self.session_rename { @@ -177,9 +177,7 @@ impl App { layers.push(panel); } #[cfg(not(target_os = "macos"))] - if self.preferences_open { - layers.push(self.preferences_panel()); - } else if let Some(path) = &self.pending_project_path { + if let Some(path) = &self.pending_project_path { layers.push(self.project_dialog(path)); } else if let Some((_, title)) = &self.session_rename { layers.push(self.rename_dialog(title)); @@ -780,10 +778,12 @@ fn hint<'a>(title: impl Into>, description: &'a str) -> Too } fn preference_group<'a>( + section: PreferenceSection, title: &'a str, content: impl Into>, ) -> Element<'a, Message> { container(column![text(title).size(11).color(muted_text()), content.into(),].spacing(10)) + .id(iced::widget::Id::new(section.anchor())) .width(Length::Fill) .padding(14) .style(preference_group_style) diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index a22a2fd..57a635e 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -81,6 +81,7 @@ impl App { } let model_group = preference_group( + PreferenceSection::Model, "MODEL & LIFECYCLE", column![ hint( @@ -129,6 +130,7 @@ impl App { .spacing(10), ); let endpoint_group = preference_group( + PreferenceSection::Endpoint, "LOCAL ENDPOINT", column![ hint( @@ -155,6 +157,7 @@ impl App { .spacing(10), ); let generation_group = preference_group( + PreferenceSection::Generation, "GENERATION", column![ preference_input_row( @@ -239,6 +242,7 @@ impl App { .spacing(10), ); let execution_group = preference_group( + PreferenceSection::Execution, "EXECUTION", column![ preference_input_row( @@ -295,6 +299,7 @@ impl App { .spacing(10), ); let acceleration_group = preference_group( + PreferenceSection::Acceleration, "ACCELERATION & MEMORY", column![ text("SPECULATIVE DECODING").size(11).color(muted_text()), @@ -424,6 +429,7 @@ impl App { .spacing(10), ); let steering_group = preference_group( + PreferenceSection::Steering, "STEERING & DIAGNOSTICS", column![ text("DIRECTIONAL STEERING").size(11).color(muted_text()), @@ -493,6 +499,7 @@ impl App { .spacing(10), ); let kv_cache_group = preference_group( + PreferenceSection::KvCache, "KV CACHE", column![ preference_input_row( @@ -556,45 +563,60 @@ impl App { if let Some(error) = &self.preference_error { fields = fields.push(text(error).style(iced::widget::text::danger)); } - let header = row![ - icon(ICON_SETTINGS, 22), - text("Preferences").size(24), - Space::new().width(Length::Fill), - text("⌘,").size(12), - ] - .spacing(10) - .align_y(Alignment::Center); + let navigation = PreferenceSection::ALL.iter().fold( + column![ + row![icon(ICON_SETTINGS, 20), text("Preferences").size(20)] + .spacing(9) + .align_y(Alignment::Center), + text("Jump to section").size(12).color(muted_text()), + rule::horizontal(1), + ] + .spacing(8), + |navigation, section| { + navigation.push( + button(text(section.label()).size(13)) + .on_press(Message::ScrollPreferences(*section)) + .width(Length::Fill) + .padding([8, 10]) + .style(button::text), + ) + }, + ); let footer = row![ action_button("Reset DS4 defaults").on_press(Message::ResetPreferences), Space::new().width(Length::Fill), - action_button("Cancel").on_press(Message::DismissPanel), + action_button("Cancel").on_press(Message::ClosePreferences), action_button("Save").on_press(Message::SavePreferences), ] .spacing(8); - let panel = container( - column![ - header, - scrollable(container(fields).padding(iced::Padding::ZERO.right(18))) - .id(preferences_scroll_id()) - .height(Length::Fill), - footer - ] - .spacing(16), - ) - .padding(24) - .width(700) + container(row![ + container(navigation) + .width(210) + .height(Length::Fill) + .padding(20) + .style(sidebar_style), + container( + column![ + row![ + text("Settings").size(24), + Space::new().width(Length::Fill), + text("⌘,").size(12).color(muted_text()), + ] + .align_y(Alignment::Center), + scrollable(container(fields).padding(iced::Padding::ZERO.right(18))) + .id(preferences_scroll_id()) + .height(Length::Fill), + footer, + ] + .spacing(16), + ) + .padding(24) + .width(Length::Fill) + .height(Length::Fill), + ]) + .width(Length::Fill) .height(Length::Fill) - .max_height(660) - .style(overview_style); - opaque( - container(panel) - .padding(24) - .center_x(Length::Fill) - .center_y(Length::Fill) - .style(|_| { - container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68)) - }), - ) + .into() } }