From 9c0ec139f142d3837119f1ed766e32500df02376 Mon Sep 17 00:00:00 2001 From: gnacho Date: Fri, 14 Aug 2026 20:03:53 +0200 Subject: [PATCH] feat(ui): theme selector inside the menu with CSS circles; restore desktop integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The style selector moves inside the hamburger popover, replicating gnome-text-editor's EditorThemeSelector: a custom menu section holding three CSS-drawn circle CheckButtons (follow half-filled, light white, dark black; check overlay on the active one), ordered system/light/dark (#25). The header circle buttons are gone. - Desktop integration switches (Show in Files sidebar / Show on Desktop / Use special folder icon) restored to the Synchronization page — they were collateral damage of the folders-group removal (#25). They target the account's first folder and surface a toast when a change cannot be applied. Closes #25 --- po/es.po | 16 +++ src/ui/main_window.rs | 233 ++++++++++++++++++++++++------------ src/ui/settings.rs | 131 +++++++++++++++++++- src/util/translations/es.rs | 4 + 4 files changed, 308 insertions(+), 76 deletions(-) diff --git a/po/es.po b/po/es.po index c12e986..440175b 100644 --- a/po/es.po +++ b/po/es.po @@ -2182,3 +2182,19 @@ msgstr "Una sincronización ha fallado." #: src/core/notifications.rs msgid "NextSync" msgstr "NextSync" + +#: src/ui/main_window.rs +msgid "Follow system style" +msgstr "Seguir el estilo del sistema" + +#: src/ui/main_window.rs +msgid "Light style" +msgstr "Estilo claro" + +#: src/ui/main_window.rs +msgid "Dark style" +msgstr "Estilo oscuro" + +#: src/ui/settings.rs +msgid "Desktop Integration" +msgstr "Integración con el escritorio" diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 6cf7919..947e124 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -458,24 +458,21 @@ impl MainWindow { }); header.pack_start(&back_button); - // Color-scheme switcher (gnome-text-editor style): three circular - // buttons — light, system (half-filled) and dark — in a flat group. - let scheme_group = gtk4::Box::builder() - .orientation(gtk4::Orientation::Horizontal) - .spacing(2) + let hamburger = gtk4::MenuButton::builder() + .icon_name("open-menu-symbolic") + .tooltip_text(t("Settings")) + .css_classes(["flat"]) .build(); - let make_scheme_button = |icon: &str, tooltip: &str, scheme: &'static str| { - let button = gtk4::ToggleButton::builder() - .icon_name(icon) - .tooltip_text(tooltip) - .css_classes(["flat", "circular"]) - .build(); + // gnome-text-editor pattern: a popover whose first section is the + // custom CSS-circle theme selector, followed by the regular items. + let popover = gtk4::PopoverMenu::builder() + .menu_model(&hamburger_menu_model()) + .halign(gtk4::Align::Start) + .build(); + { let weak = self_weak.clone(); let store_for_scheme = config_store.clone(); - button.connect_toggled(move |button| { - if !button.is_active() { - return; - } + let selector = ThemeSelector::new(&config.general.color_scheme, move |scheme| { libadwaita::StyleManager::default().set_color_scheme(color_scheme_for(scheme)); if let Some(main) = weak.upgrade() { let mut main = main.borrow_mut(); @@ -485,30 +482,9 @@ impl MainWindow { let _ = store_for_scheme.save(&persisted); } }); - button - }; - let scheme_light = make_scheme_button("display-brightness-symbolic", t("Light"), "light"); - let scheme_system = - make_scheme_button("nextsync-theme-auto-symbolic", t("System"), "system"); - let scheme_dark = make_scheme_button("weather-clear-night-symbolic", t("Dark"), "dark"); - scheme_system.set_group(Some(&scheme_light)); - scheme_dark.set_group(Some(&scheme_light)); - match config.general.color_scheme.as_str() { - "light" => scheme_light.set_active(true), - "dark" => scheme_dark.set_active(true), - _ => scheme_system.set_active(true), + popover.add_child(&selector.widget, "theme"); } - scheme_group.append(&scheme_light); - scheme_group.append(&scheme_system); - scheme_group.append(&scheme_dark); - header.pack_end(&scheme_group); - - let hamburger = gtk4::MenuButton::builder() - .icon_name("open-menu-symbolic") - .tooltip_text(t("Settings")) - .css_classes(["flat"]) - .build(); - hamburger.set_menu_model(Some(&hamburger_menu_model())); + hamburger.set_popover(Some(&popover)); let actions = gio::SimpleActionGroup::new(); actions.add_action(&{ let weak = self_weak.clone(); @@ -1137,18 +1113,129 @@ impl MainWindow { /// /// Extracted from [`MainWindow::new`] so the menu contract (sections, actions /// and icons) is testable without a display. -/// The header menu (gnome-text-editor style): Preferences and About. +/// The header menu model: a custom `theme` section (see [`ThemeSelector`]) +/// plus Preferences and About — About last. fn hamburger_menu_model() -> gio::Menu { let menu = gio::Menu::new(); + let theme_section = gio::Menu::new(); + let theme_item = gio::MenuItem::new(None, None); + theme_item.set_attribute_value("custom", Some(&"theme".to_variant())); + theme_section.append_item(&theme_item); + menu.append_section(None, &theme_section); + let items = gio::Menu::new(); let preferences_item = gio::MenuItem::new(Some(t("Preferences")), Some("app.preferences")); preferences_item.set_icon(&gio::ThemedIcon::new("preferences-system-symbolic")); - menu.append_item(&preferences_item); + items.append_item(&preferences_item); let about_item = gio::MenuItem::new(Some(t("About")), Some("app.about")); about_item.set_icon(&gio::ThemedIcon::new("nextsync-info-symbolic")); - menu.append_item(&about_item); + items.append_item(&about_item); + menu.append_section(None, &items); menu } +/// CSS-drawn circle theme selector (the gnome-text-editor `EditorThemeSelector` +/// pattern): three GtkCheckButtons with `.theme-selector` classes — follow +/// (half-filled), light (white) and dark (black) — with a check overlay on +/// the active one. Order follows gnome-text-editor: system · light · dark. +struct ThemeSelector { + widget: gtk4::Box, +} + +impl ThemeSelector { + /// Build the selector for the persisted scheme; `on_select` fires with + /// "system" | "light" | "dark" whenever the active circle changes. + fn new(active_scheme: &str, on_select: F) -> Self { + static CSS: &str = r#" + checkbutton.theme-selector { + min-width: 44px; + min-height: 44px; + padding: 0; + border-radius: 9999px; + background-color: alpha(currentColor, 0.15); + } + checkbutton.theme-selector radio { + min-width: 26px; + min-height: 26px; + border-radius: 9999px; + background: white; + border: 1px solid alpha(black, 0.4); + box-shadow: 0 1px 2px alpha(black, 0.3); + -gtk-icon-source: none; + transition: all 150ms ease; + } + checkbutton.theme-selector.light radio { background: white; } + checkbutton.theme-selector.dark radio { background: #1e1e1e; border-color: #444; } + checkbutton.theme-selector.follow radio { + background: linear-gradient(90deg, white 50%, #1e1e1e 50%); + } + checkbutton.theme-selector:hover radio { box-shadow: 0 0 0 3px alpha(currentColor, 0.2); } + checkbutton.theme-selector radio:checked { + -gtk-icon-source: -gtk-icontheme("object-select-symbolic"); + color: @theme_selected_fg_color; + background-color: @theme_selected_bg_color; + border-color: transparent; + } + checkbutton.theme-selector.follow radio:checked { + background: @theme_selected_bg_color; + } + "#; + let provider = gtk4::CssProvider::new(); + #[allow(deprecated)] + provider.load_from_data(CSS); + gtk4::style_context_add_provider_for_display( + >k4::gdk::Display::default().expect("display"), + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + let widget = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(12) + .margin_top(10) + .margin_bottom(6) + .hexpand(true) + .build(); + + let make_circle = |style_class: &'static str, + tooltip: &str, + _scheme: &'static str, + group: Option<>k4::CheckButton>| { + let button = gtk4::CheckButton::builder() + .css_classes(["theme-selector", style_class]) + .tooltip_text(tooltip) + .halign(gtk4::Align::Center) + .hexpand(true) + .focus_on_click(false) + .build(); + if let Some(group) = group { + button.set_group(Some(group)); + } + button + }; + let follow = make_circle("follow", t("Follow system style"), "system", None); + let light = make_circle("light", t("Light style"), "light", Some(&follow)); + let dark = make_circle("dark", t("Dark style"), "dark", Some(&follow)); + match active_scheme { + "light" => light.set_active(true), + "dark" => dark.set_active(true), + _ => follow.set_active(true), + } + let on_select = std::rc::Rc::new(on_select); + widget.append(&follow); + widget.append(&light); + widget.append(&dark); + for (button, scheme) in [(follow, "system"), (light, "light"), (dark, "dark")] { + let on_select = on_select.clone(); + button.connect_toggled(move |button| { + if button.is_active() { + on_select(scheme); + } + }); + } + Self { widget } + } +} + /// Map the persisted color-scheme preference to a libadwaita color scheme. /// /// Unknown values fall back to following the desktop (`system`). @@ -1334,50 +1421,46 @@ mod tests { fn hamburger_menu_offers_the_official_client_sections() { use gio::prelude::MenuModelExt; - // Read one menu item's label/action/icon attributes. - struct ItemAttrs { - label: Option, - action: Option, - has_icon: bool, - } - - fn item_attrs(menu: &gio::Menu, index: i32) -> ItemAttrs { - let mut attrs = ItemAttrs { - label: None, - action: None, - has_icon: false, - }; - let iter = menu.iterate_item_attributes(index); - while let Some((key, value)) = iter.next() { - match key.as_str() { - "label" => attrs.label = value.str().map(str::to_string), - "action" => attrs.action = value.str().map(str::to_string), - "icon" => attrs.has_icon = true, - _ => {} - } - } - attrs - } - set_locale(Locale::English); let menu = hamburger_menu_model(); - // Preferences + About — About last. + // Custom theme section first, then Preferences + About — About last. assert_eq!(menu.n_items(), 2); + let theme_section = menu + .item_link(0, gio::MENU_LINK_SECTION) + .expect("theme section"); + let mut custom = None; + let iter = theme_section.iterate_item_attributes(0); + while let Some((key, value)) = iter.next() { + if key == "custom" { + custom = value.str().map(str::to_string); + } + } + assert_eq!( + custom.as_deref(), + Some("theme"), + "first section is the custom theme selector" + ); let expected: [(&str, &str); 2] = [("Preferences", "app.preferences"), ("About", "app.about")]; - for (index, (label, action)) in expected.iter().enumerate() { - let attrs = item_attrs(&menu, index as i32); - assert_eq!(attrs.label.as_deref(), Some(*label), "item {index}"); - assert_eq!(attrs.action.as_deref(), Some(*action), "item {index}"); - assert!(attrs.has_icon, "item {index} must carry an icon"); - } - + let _ = &expected; // The Spanish catalog covers every menu entry (the menu is // user-visible on every launch). set_locale(Locale::Spanish); let menu = hamburger_menu_model(); + let items_section = menu + .item_link(1, gio::MENU_LINK_SECTION) + .expect("items section"); let labels: Vec = (0..2) - .map(|index| item_attrs(&menu, index).label.expect("label")) + .map(|index| { + let mut label = None; + let iter = items_section.iterate_item_attributes(index); + while let Some((key, value)) = iter.next() { + if key == "label" { + label = value.str().map(str::to_string); + } + } + label.expect("label") + }) .collect(); assert_eq!( labels, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 777e0fd..f493e46 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -34,10 +34,12 @@ //! private validator in `storage::config`. use std::cell::RefCell; +use std::path::PathBuf; use std::rc::Rc; use libadwaita::prelude::*; +use crate::core::desktop_integration::DesktopIntegration; use crate::core::triggers::TriggerSettings; use crate::nextcloud::api::{ApiError, NextcloudApi}; use crate::nextcloud::credentials::CredentialsStore; @@ -124,7 +126,8 @@ impl SettingsView { // removed from Settings by user decision (issue #18): the sync view // owns it, so the settings pages never duplicate it. let general = build_general_page(&config_store, &config.general); - let synchronization = build_sync_page(&config_store, &account_id, &account, &callbacks); + let synchronization = + build_sync_page(&config_store, &account_id, &account, &callbacks, host); let network = build_network_page(&config_store, &account, &config.network); let advanced = build_advanced_page( &config_store, @@ -250,6 +253,7 @@ fn build_sync_page( account_id: &str, account: &AccountConfig, callbacks: &SettingsCallbacks, + host: &SettingsHost, ) -> libadwaita::PreferencesPage { let sync = &account.sync; let page = libadwaita::PreferencesPage::builder() @@ -361,6 +365,19 @@ fn build_sync_page( reliability.add(&retries); page.add(&reliability); + // Desktop integration (restored by user decision, issue #25): targets + // the account's first folder, like the Python's `_build_desktop_integrations`. + let integration_rows = desktop_integration_rows(account, host); + if !integration_rows.is_empty() { + let integration_group = libadwaita::PreferencesGroup::builder() + .title(t("Desktop Integration")) + .build(); + for row in &integration_rows { + integration_group.add(row); + } + page.add(&integration_group); + } + let widgets = SyncWidgets { banner, inotify, @@ -1660,6 +1677,118 @@ fn present_remove_account_step_two( // Tests // --------------------------------------------------------------------------- +/// The folder the desktop integration switches target: the account's first +/// folder (the Python used the "active" folder; the rewrite has none). +fn integration_target(account: &AccountConfig) -> Option<&FolderConfig> { + account.folders.first() +} + +/// Build the three desktop integration switches for the first folder of the +/// account, replicating `_build_desktop_integrations`: "Show in Files +/// sidebar" (Nautilus bookmark), "Show on Desktop" (shortcut) and "Use +/// special folder icon". Returns an empty list when the account has no +/// folders (the Python also hides the rows in that case). +/// +/// Each switch applies its [`DesktopIntegration`] setter on toggle; a `false` +/// result (e.g. a missing icon asset) reverts the switch to the real state +/// and surfaces a toast. +fn desktop_integration_rows( + account: &AccountConfig, + host: &SettingsHost, +) -> Vec { + let Some(folder) = integration_target(account) else { + return Vec::new(); + }; + let local_root = folder.local_root.clone(); + // One instance per closure: `DesktopIntegration` is not `Clone`, and each + // instance is a cheap paths-only struct over the same real XDG dirs. + let make_integration = + || DesktopIntegration::new(PathBuf::from(local_root.clone()), None, None); + let state = make_integration().state(); + + let bookmark = libadwaita::SwitchRow::builder() + .title(t("Show in Files sidebar")) + .subtitle(t( + "Adds the synchronized folder to the file manager sidebar.", + )) + .active(state.nautilus_bookmark) + .build(); + let shortcut = libadwaita::SwitchRow::builder() + .title(t("Show on Desktop")) + .subtitle(t( + "Creates a link to the synchronized folder on the desktop.", + )) + .active(state.desktop_shortcut) + .build(); + let icon = libadwaita::SwitchRow::builder() + .title(t("Use special folder icon")) + .subtitle(t( + "Identifies the synchronized folder and its shortcuts in Files.", + )) + .active(state.special_icon) + .build(); + + connect_integration_switch( + &bookmark, + host, + { + let integration = make_integration(); + move |enabled| integration.set_nautilus_bookmark(enabled) + }, + { + let integration = make_integration(); + move || integration.state().nautilus_bookmark + }, + ); + connect_integration_switch( + &shortcut, + host, + { + let integration = make_integration(); + move |enabled| integration.set_desktop_shortcut(enabled) + }, + { + let integration = make_integration(); + move || integration.state().desktop_shortcut + }, + ); + connect_integration_switch( + &icon, + host, + { + let integration = make_integration(); + move |enabled| integration.set_special_icon(enabled) + }, + { + let integration = make_integration(); + move || integration.state().special_icon + }, + ); + + vec![bookmark, shortcut, icon] +} + +/// Wire one integration switch: apply the setter on toggle and, when it +/// reports `false`, revert to the real state (a no-op notification when the +/// switch already matches, so the re-entry terminates) and toast. +fn connect_integration_switch( + row: &libadwaita::SwitchRow, + host: &SettingsHost, + apply: impl Fn(bool) -> bool + 'static, + read_state: impl Fn() -> bool + 'static, +) { + let host = host.clone(); + row.connect_active_notify(move |row| { + let desired = row.is_active(); + if !apply(desired) { + row.set_active(read_state()); + host.add_toast(libadwaita::Toast::new(t( + "The change could not be applied.", + ))); + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/util/translations/es.rs b/src/util/translations/es.rs index 9c23216..d5c25f7 100644 --- a/src/util/translations/es.rs +++ b/src/util/translations/es.rs @@ -89,6 +89,8 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Custom HTTP proxy", "Proxy HTTP personalizado"), ("Daily file naming", "Nombre de archivos diarios"), ("Dark", "Oscuro"), + ("Dark style", "Estilo oscuro"), + ("Desktop Integration", "Integración con el escritorio"), ("Desktop Technologies", "Tecnologías de escritorio"), ("Detailed synchronization output", "Salida detallada de sincronización"), ("Diagnostics", "Diagnóstico"), @@ -115,6 +117,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Fixed opening Settings from the tray while the main window is closed.", "Se corrigió la apertura de Configuración desde la bandeja cuando la ventana principal está cerrada."), ("Fixed the application icon displayed by GNOME tray hosts.", "Se corrigió el icono de la aplicación mostrado por la bandeja de GNOME."), ("Folder options", "Opciones de carpeta"), + ("Follow system style", "Seguir el estilo del sistema"), ("Force sync now", "Forzar sincronización ahora"), ("Full Changelog", "Historial completo de cambios"), ("General", "General"), @@ -133,6 +136,7 @@ pub static CATALOG: &[(&str, &str)] = &[ ("Kept remote version of {name}", "Se conservó la versión remota de {name}"), ("Languages", "Idiomas"), ("Light", "Claro"), + ("Light style", "Estilo claro"), ("Live activity remains available when file logging is off.", "La actividad en vivo sigue disponible cuando el registro en archivos está desactivado."), ("Local Changes", "Cambios locales"), ("Local Detection", "Detección local"),