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
This commit is contained in:
Roman Makarov authored and GitHub committed 2026-09-20 10:35:55 +03:00
1 parent 38874d1301
commit a2323301c5
7 files changed
+417 -43

No files matched your search

+4
View File
@@ -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
+15
View File
@@ -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;
}
+63 -42
View File
@@ -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<AppMsg> = 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<SoftwarePage>,
displays_page: relm4::Controller<DisplaysPage>,
crash_page: relm4::Controller<CrashPage>,
page_navigation: relm4::Controller<PageNavigation>,
service_setup_dialog: Option<AsyncController<ServiceSetupDialog>>,
gpu_selector: relm4::Controller<GpuSelector>,
@@ -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 = &gtk::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() {
+192
View File
@@ -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 = &gtk::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<Self>,
) -> ComponentParts<Self> {
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<Self>,
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<Self::Output>) {
widgets.window.destroy();
}
}
+118
View File
@@ -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<relm4::Controller<DetachablePage>>,
}
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<Self>,
) -> ComponentParts<Self> {
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<Self>,
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);
}
}
}
}
}
+23
View File
@@ -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<DeviceInfo>),
+2 -1
View File
@@ -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()
}