From a2323301c58e899d03219f7828b9ff8287a30dec Mon Sep 17 00:00:00 2001 From: Roman Makarov Date: Sun, 20 Sep 2026 09:35:55 +0200 Subject: [PATCH] feat: detachable pages (#1203) * wip * refactor * markup fixes * use static sidebar min-width * upd spacing * move detachable page * toggle detach on sidebar's elm icon click * simplify wording * fix * small cleanup * clippy * small ref * use enum for pageId * replace set_transient_for with set_application * show detach button for selected page * shrink icon --- lact-gui/i18n/en/lact_gui.ftl | 4 + lact-gui/src/app.css | 15 +++ lact-gui/src/app.rs | 105 +++++++++------ lact-gui/src/app/detachable_page.rs | 192 ++++++++++++++++++++++++++++ lact-gui/src/app/page_navigation.rs | 118 +++++++++++++++++ lact-gui/src/app/pages/mod.rs | 23 ++++ lact-gui/src/config.rs | 3 +- 7 files changed, 417 insertions(+), 43 deletions(-) create mode 100644 lact-gui/src/app/detachable_page.rs create mode 100644 lact-gui/src/app/page_navigation.rs diff --git a/lact-gui/i18n/en/lact_gui.ftl b/lact-gui/i18n/en/lact_gui.ftl index d3744076..ab20e903 100644 --- a/lact-gui/i18n/en/lact_gui.ftl +++ b/lact-gui/i18n/en/lact_gui.ftl @@ -7,6 +7,10 @@ oc-page = Overclocking thermals-page = Thermals software-page = Software Info displays-page = Display Info +detach-page = Detach {$page} +page-detached = This page is open in another window. +show-page-window = Focus Window +reattach-page = Return to Main Window hardware-info = Hardware Information diff --git a/lact-gui/src/app.css b/lact-gui/src/app.css index 8abc2fcc..55a10998 100644 --- a/lact-gui/src/app.css +++ b/lact-gui/src/app.css @@ -13,3 +13,18 @@ .main-sidebar-container .sidebar-section { margin: 4px 8px 8px 8px; } + +.page-detach-button { + -gtk-icon-size: 12px; + min-width: 26px; + min-height: 26px; + padding: 0; + margin: 4px 0; + opacity: 0; +} + +.navigation-sidebar row:hover .page-detach-button, +.navigation-sidebar row:selected .page-detach-button, +.page-detach-button:focus-visible { + opacity: 1; +} diff --git a/lact-gui/src/app.rs b/lact-gui/src/app.rs index 6f962fd4..f847e177 100644 --- a/lact-gui/src/app.rs +++ b/lact-gui/src/app.rs @@ -1,10 +1,12 @@ mod about_dialog; pub(crate) mod components; +mod detachable_page; mod gpu_selector; pub mod graphs_window; mod info_dialog; pub(crate) mod msg; mod overdrive_dialog; +mod page_navigation; pub(crate) mod pages; mod preferences_dialog; mod process_monitor; @@ -49,8 +51,9 @@ use lact_schema::{ request::{ConfirmCommand, ProfileBase, SetClocksCommand}, }; use msg::AppMsg; +use page_navigation::{PageNavigation, PageNavigationInit, PageNavigationMsg}; use pages::{ - PageUpdate, + PageId, PageUpdate, crash_page::CrashPage, info_page::InformationPage, oc_page::{OcPage, OcPageMsg}, @@ -85,7 +88,6 @@ pub(crate) static APP_BROKER: MessageBroker = MessageBroker::new(); const PROCESS_POLL_INTERVAL_MS: u64 = 1500; const NVIDIA_RECOMMENDED_MIN_VERSION: u32 = 560; -const CONTENT_MAXIMUM_WIDTH: i32 = 1200; const DEFAULT_WINDOW_WIDTH: i32 = 1100; const DEFAULT_WINDOW_HEIGHT: i32 = 750; const CONFIRM_RESPONSE_APPLY: &str = "confirm"; @@ -117,6 +119,7 @@ pub struct AppModel { software_page: relm4::Controller, displays_page: relm4::Controller, crash_page: relm4::Controller, + page_navigation: relm4::Controller, service_setup_dialog: Option>, gpu_selector: relm4::Controller, @@ -183,6 +186,7 @@ impl AsyncComponent for AppModel { #[name = "navbar"] adw::NavigationSplitView { set_expand: true, + set_min_sidebar_width: 230.0, set_max_sidebar_width: 230.0, #[wrap(Some)] @@ -209,11 +213,7 @@ impl AsyncComponent for AppModel { gtk::Separator {}, - gtk::StackSidebar { - set_margin_vertical: 1, - set_stack: &root_stack, - set_vexpand: true, - }, + model.page_navigation.widget(), gtk::Separator {}, @@ -281,43 +281,26 @@ impl AsyncComponent for AppModel { }, #[wrap(Some)] - set_content = >k::ScrolledWindow { - set_hscrollbar_policy: gtk::PolicyType::Never, + #[name = "root_stack"] + set_content = &model.page_navigation.widgets().stack.clone() -> gtk::Stack { + add_binding: (&model.ui_sensitive, "sensitive"), - adw::Clamp { - set_maximum_size: CONTENT_MAXIMUM_WIDTH, - set_tightening_threshold: CONTENT_MAXIMUM_WIDTH, + add_named[Some(PageId::Crash.as_str())] = model.crash_page.widget(), - #[name = "root_stack"] - gtk::Stack { - set_vexpand: false, - set_vhomogeneous: false, + set_visible_child_name: &CONFIG.read().selected_tab, + connect_visible_child_name_notify[content_page] => move |stack| { + if let Some(child) = stack.visible_child() { + let page = stack.page(&child); + content_page.set_title(&page.title().unwrap_or_default()); - add_binding: (&model.ui_sensitive, "sensitive"), - - add_titled[Some("info_page"), &fl!(I18N, "info-page")] = model.info_page.widget(), - add_titled[Some("oc_page"), &fl!(I18N, "oc-page")] = model.oc_page.widget(), - add_titled[Some("thermals_page"), &fl!(I18N, "thermals-page")] = model.thermals_page.widget(), - add_titled[Some("software_page"), &fl!(I18N, "software-page")] = model.software_page.widget(), - add_titled[Some("displays_page"), &fl!(I18N, "displays-page")] = model.displays_page.widget(), - add_named[Some("crash_page")] = model.crash_page.widget(), - - set_visible_child_name: &CONFIG.read().selected_tab, - connect_visible_child_name_notify[content_page] => move |stack| { - if let Some(child) = stack.visible_child() { - let page = stack.page(&child); - content_page.set_title(&page.title().unwrap_or_default()); - - let name = stack.visible_child_name().unwrap().to_string(); - if name != "crash_page" { - CONFIG.write().edit(|config| { - config.selected_tab = name; - }); - } - } - }, + let name = stack.visible_child_name().unwrap().to_string(); + if name != PageId::Crash.as_str() { + CONFIG.write().edit(|config| { + config.selected_tab = name; + }); + } } - } + }, }, } } @@ -428,6 +411,39 @@ impl AsyncComponent for AppModel { let displays_page = DisplaysPage::detach_default(); + let ui_sensitive = BoolBinding::new(false); + let page_navigation = PageNavigation::detach(PageNavigationInit { + pages: vec![ + ( + PageId::Info, + fl!(I18N, "info-page"), + info_page.widget().clone().upcast(), + ), + ( + PageId::Oc, + fl!(I18N, "oc-page"), + oc_page.widget().clone().upcast(), + ), + ( + PageId::Thermals, + fl!(I18N, "thermals-page"), + thermals_page.widget().clone().upcast(), + ), + ( + PageId::Software, + fl!(I18N, "software-page"), + software_page.widget().clone().upcast(), + ), + ( + PageId::Displays, + fl!(I18N, "displays-page"), + displays_page.widget().clone().upcast(), + ), + ], + parent: root.clone(), + sensitive: ui_sensitive.clone(), + }); + let crash_page = CrashPage::launch_default().forward(sender.input_sender(), |msg| msg); let overdrive_dialog = @@ -493,10 +509,11 @@ impl AsyncComponent for AppModel { software_page, crash_page, displays_page, + page_navigation, gpu_selector, profile_selector, service_setup_dialog: None, - ui_sensitive: BoolBinding::new(false), + ui_sensitive, is_reconnecting: BoolBinding::new(false), stats_task_handle: None, settings_changed, @@ -845,6 +862,7 @@ impl AppModel { result?; } AppMsg::EnablePstateConfig => { + root.present(); self.info_dialog .emit(InfoDialogMsg::Show(Box::new(InfoDialogData { id: InfoDialogId::EnablePstateConfigConfirmation, @@ -923,6 +941,7 @@ impl AppModel { self.reload_profiles(None).await?; } AppMsg::Crash(message) => { + self.page_navigation.emit(PageNavigationMsg::CloseWindows); // we cannot be sure that the application is fully functional after a crash // even though the main loop is restored via crash handler, we want user to restart // this is why navigation controls are disabled @@ -931,7 +950,9 @@ impl AppModel { self.settings_changed.set_value(false); self.ui_sensitive.set_value(true); - widgets.root_stack.set_visible_child_name("crash_page"); + widgets + .root_stack + .set_visible_child_name(PageId::Crash.as_str()); self.crash_page.emit(message); if let Some(handle) = self.stats_task_handle.take() { diff --git a/lact-gui/src/app/detachable_page.rs b/lact-gui/src/app/detachable_page.rs new file mode 100644 index 00000000..ad42edd1 --- /dev/null +++ b/lact-gui/src/app/detachable_page.rs @@ -0,0 +1,192 @@ +use crate::{ + I18N, + app::{DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_WIDTH, pages::PageId}, +}; +use adw::prelude::*; +use gtk::glib; +use i18n_embed_fl::fl; +use relm4::{ + ComponentParts, ComponentSender, RelmObjectExt, RelmWidgetExt, binding::BoolBinding, css, +}; + +const CONTENT_MAXIMUM_WIDTH: i32 = 1200; + +pub struct DetachablePage { + pub init: DetachablePageInit, + detached: bool, +} + +pub struct DetachablePageInit { + pub id: PageId, + pub title: String, + pub content: gtk::Widget, + pub parent: adw::ApplicationWindow, + pub sensitive: BoolBinding, +} + +#[derive(Debug)] +pub enum DetachablePageMsg { + ToggleDetached, + Detach, + Attach, +} + +#[relm4::component(pub)] +impl relm4::Component for DetachablePage { + type Init = DetachablePageInit; + type Input = DetachablePageMsg; + type Output = (); + type CommandOutput = (); + + view! { + #[root] + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + + #[name = "content"] + gtk::ScrolledWindow { + set_hscrollbar_policy: gtk::PolicyType::Never, + set_vexpand: true, + add_binding: (&model.init.sensitive, "sensitive"), + + adw::Clamp { + set_maximum_size: CONTENT_MAXIMUM_WIDTH, + set_tightening_threshold: CONTENT_MAXIMUM_WIDTH, + set_child: Some(&model.init.content), + }, + }, + + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 15, + set_margin_all: 15, + set_align: gtk::Align::Center, + set_vexpand: true, + #[watch] + set_visible: model.detached, + + gtk::Label { + set_label: &fl!(I18N, "page-detached"), + }, + gtk::Button { + set_label: &fl!(I18N, "show-page-window"), + connect_clicked => DetachablePageMsg::Detach, + }, + gtk::Button { + set_label: &fl!(I18N, "reattach-page"), + connect_clicked => DetachablePageMsg::Attach, + }, + }, + }, + + #[name = "row"] + gtk::ListBoxRow { + update_property: &[gtk::accessible::Property::Label(&model.init.title)], + #[wrap(Some)] + set_child = >k::Box { + set_spacing: 5, + gtk::Label { + set_label: &model.init.title, + set_halign: gtk::Align::Start, + set_hexpand: true, + }, + gtk::Button { + set_icon_name: "window-new-symbolic", + #[watch] + set_tooltip_text: Some(&if model.detached { + fl!(I18N, "reattach-page") + } else { + fl!(I18N, "detach-page", page = model.init.title.as_str()) + }), + add_css_class: css::FLAT, + add_css_class: "page-detach-button", + connect_clicked => DetachablePageMsg::ToggleDetached, + }, + }, + }, + + #[name = "window"] + adw::Window { + set_title: Some(&model.init.title), + set_default_width: DEFAULT_WINDOW_WIDTH, + set_default_height: DEFAULT_WINDOW_HEIGHT, + set_application: model.init.parent.application().as_ref(), + #[watch] + set_visible: model.detached, + connect_close_request[sender] => move |_| { + sender.input(DetachablePageMsg::Attach); + glib::Propagation::Stop + }, + + #[name = "toolbar"] + adw::ToolbarView { + add_top_bar = &adw::HeaderBar {}, + }, + }, + + #[local_ref] + parent -> adw::ApplicationWindow { + connect_close_request[sender, window] => move |_| { + // Hide immediately, before the application's main loop can stop. + window.set_visible(false); + window.set_application(gtk::Application::NONE); + sender.input(DetachablePageMsg::Attach); + glib::Propagation::Proceed + }, + }, + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let model = Self { + init, + detached: false, + }; + let parent = &model.init.parent; + let widgets = view_output!(); + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: Self::Input, + sender: ComponentSender, + root: &Self::Root, + ) { + match msg { + DetachablePageMsg::ToggleDetached => { + sender.input(if self.detached { + DetachablePageMsg::Attach + } else { + DetachablePageMsg::Detach + }); + } + DetachablePageMsg::Detach => { + if !self.detached { + root.remove(&widgets.content); + widgets.toolbar.set_content(Some(&widgets.content)); + // Focusing the first input would scroll the page back to that input. + GtkWindowExt::set_focus(&widgets.window, Some(&widgets.content)); + self.detached = true; + } + widgets.window.present(); + } + DetachablePageMsg::Attach => { + if self.detached { + widgets.toolbar.set_content(gtk::Widget::NONE); + root.append(&widgets.content); + self.detached = false; + } + } + } + self.update_view(widgets, sender); + } + + fn shutdown(&mut self, widgets: &mut Self::Widgets, _output: relm4::Sender) { + widgets.window.destroy(); + } +} diff --git a/lact-gui/src/app/page_navigation.rs b/lact-gui/src/app/page_navigation.rs new file mode 100644 index 00000000..62e95fcf --- /dev/null +++ b/lact-gui/src/app/page_navigation.rs @@ -0,0 +1,118 @@ +use super::{ + detachable_page::{DetachablePage, DetachablePageInit, DetachablePageMsg}, + pages::PageId, + utils::ext::RelmLaunchable, +}; +use adw::prelude::*; +use relm4::{ComponentController, ComponentParts, ComponentSender, binding::BoolBinding}; + +pub struct PageNavigation { + pages: Vec>, +} + +pub struct PageNavigationInit { + pub pages: Vec<(PageId, String, gtk::Widget)>, + pub parent: adw::ApplicationWindow, + pub sensitive: BoolBinding, +} + +#[derive(Debug)] +pub enum PageNavigationMsg { + Select(usize), + SyncSelection, + CloseWindows, +} + +#[relm4::component(pub)] +impl relm4::Component for PageNavigation { + type Init = PageNavigationInit; + type Input = PageNavigationMsg; + type Output = (); + type CommandOutput = (); + + view! { + #[root] + gtk::ListBox { + add_css_class: "navigation-sidebar", + set_vexpand: true, + connect_row_selected[sender] => move |_, row| { + if let Some(row) = row { + sender.input(PageNavigationMsg::Select(row.index() as usize)); + } + } @ selection_signal, + }, + + #[name = "stack"] + gtk::Stack { + set_vhomogeneous: false, + connect_visible_child_name_notify => PageNavigationMsg::SyncSelection, + }, + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let pages = init + .pages + .into_iter() + .map(|(id, title, content)| { + DetachablePage::detach(DetachablePageInit { + id, + title, + content, + parent: init.parent.clone(), + sensitive: init.sensitive.clone(), + }) + }) + .collect(); + let model = Self { pages }; + let widgets = view_output!(); + + for page in &model.pages { + root.append(&page.widgets().row); + widgets.stack.add_titled( + page.widget(), + Some(page.model().init.id.as_str()), + &page.model().init.title, + ); + } + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: Self::Input, + _sender: ComponentSender, + root: &Self::Root, + ) { + match msg { + PageNavigationMsg::Select(index) => { + widgets + .stack + .set_visible_child_name(self.pages[index].model().init.id.as_str()); + } + PageNavigationMsg::SyncSelection => { + let index = self.pages.iter().position(|page| { + Some(page.model().init.id.as_str()) + == widgets.stack.visible_child_name().as_deref() + }); + root.block_signal(&widgets.selection_signal); + root.select_row( + index + .and_then(|index| root.row_at_index(index as i32)) + .as_ref(), + ); + root.unblock_signal(&widgets.selection_signal); + } + PageNavigationMsg::CloseWindows => { + for page in &self.pages { + page.emit(DetachablePageMsg::Attach); + } + } + } + } +} diff --git a/lact-gui/src/app/pages/mod.rs b/lact-gui/src/app/pages/mod.rs index bb1be20f..a01fb3d2 100644 --- a/lact-gui/src/app/pages/mod.rs +++ b/lact-gui/src/app/pages/mod.rs @@ -8,6 +8,29 @@ pub mod thermals_page; use lact_schema::{DeviceInfo, DeviceStats}; use std::sync::Arc; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageId { + Info, + Oc, + Thermals, + Software, + Displays, + Crash, +} + +impl PageId { + pub fn as_str(self) -> &'static str { + match self { + Self::Info => "info_page", + Self::Oc => "oc_page", + Self::Thermals => "thermals_page", + Self::Software => "software_page", + Self::Displays => "displays_page", + Self::Crash => "crash_page", + } + } +} + #[derive(Debug, Clone)] pub enum PageUpdate { Info(Arc), diff --git a/lact-gui/src/config.rs b/lact-gui/src/config.rs index 4fdd1e1c..419b8b02 100644 --- a/lact-gui/src/config.rs +++ b/lact-gui/src/config.rs @@ -1,5 +1,6 @@ use crate::app::{ graphs_window::stat::StatType, + pages::PageId, utils::{color_scheme::AppColorScheme, styles::AppTheme}, }; use serde::{Deserialize, Deserializer, Serialize}; @@ -125,5 +126,5 @@ fn deserialize_poll_interval<'de, D: Deserializer<'de>>(deserializer: D) -> Resu } fn default_tab() -> String { - "info_page".to_owned() + PageId::Info.as_str().to_owned() }