Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ package() {
nextsync-status-syncing-symbolic
nextsync-info-symbolic
nextsync-settings-2-symbolic
nextsync-undo-2-symbolic
)
for icon in "${symbolic[@]}"; do
install -Dm644 "data/icons/$icon.svg" \
Expand Down
14 changes: 14 additions & 0 deletions data/icons/nextsync-undo-2-symbolic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions po/es.po
Original file line number Diff line number Diff line change
Expand Up @@ -2138,3 +2138,19 @@ msgstr "Error de sincronización — consulta el registro"

#~ msgid "Use This Folder"
#~ msgstr "Usar esta carpeta"

#: src/ui/main_window.rs
msgid "System"
msgstr "Sistema"

#: src/ui/main_window.rs
msgid "Light"
msgstr "Claro"

#: src/ui/main_window.rs
msgid "Dark"
msgstr "Oscuro"

#: src/ui/settings.rs
msgid "Remove account"
msgstr "Quitar cuenta"
5 changes: 5 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,17 @@ fn main() {
};
let config = match config_store.load() {
Ok(config) => config,

Err(error) => {
eprintln!("Could not load configuration: {error}");
std::process::exit(1);
}
};

// Apply the persisted color scheme before any window is shown.
libadwaita::StyleManager::default()
.set_color_scheme(nextsync::ui::color_scheme_for(&config.general.color_scheme));

let source: Rc<RefCell<dyn nextsync::core::debounce::TimeoutSource>> =
Rc::new(RefCell::new(GlibTimeoutSource::new()));
let logger = nextsync::core::log::LogBuffer::new();
Expand Down
17 changes: 17 additions & 0 deletions src/storage/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,22 @@ pub struct RuntimeConfig {
pub struct GeneralConfig {
pub autostart: bool,
pub pause_on_battery: bool,
/// Color scheme preference: `"system"`, `"light"` or `"dark"`.
#[serde(default = "default_color_scheme")]
pub color_scheme: String,
}

/// Default color scheme (follow the desktop).
fn default_color_scheme() -> String {
"system".to_string()
}

impl Default for GeneralConfig {
fn default() -> Self {
Self {
autostart: true,
pause_on_battery: false,
color_scheme: default_color_scheme(),
}
}
}
Expand Down Expand Up @@ -988,6 +997,7 @@ fn validate_general(raw: Option<&Value>) -> GeneralConfig {
GeneralConfig {
autostart: get_bool(obj, "autostart", true),
pause_on_battery: get_bool(obj, "pause_on_battery", false),
color_scheme: get_string(obj, "color_scheme", &default_color_scheme()),
}
}

Expand Down Expand Up @@ -1159,6 +1169,13 @@ fn get_bool(obj: &Map<String, Value>, key: &str, default: bool) -> bool {
}
}

fn get_string(obj: &Map<String, Value>, key: &str, default: &str) -> String {
match obj.get(key) {
Some(Value::String(text)) => text.clone(),
_ => default.to_string(),
}
}

fn sha256_hex(input: &[u8]) -> String {
let digest = Sha256::digest(input);
digest.iter().map(|byte| format!("{byte:02x}")).collect()
Expand Down
172 changes: 116 additions & 56 deletions src/ui/main_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,26 +374,29 @@ impl MainWindow {
let title = libadwaita::WindowTitle::new(window_title(), window_subtitle());
header.set_title_widget(Some(&title));

// Hamburger menu (official-client style): Preferences, Advanced and
// About, rendered in-app by sliding the settings view over the sync
// view.
// gnome-text-editor layout: the back-to-sync button sits at the left
// of the header; the hamburger menu sits at the far right, next to
// the window close button.
let back_button = gtk4::Button::builder()
.icon_name("nextsync-undo-2-symbolic")
.tooltip_text(t("Synchronization"))
.css_classes(["flat"])
.build();
let back_weak = self_weak.clone();
back_button.connect_clicked(move |_button| {
if let Some(main) = back_weak.upgrade() {
main.borrow_mut().show_sync_view();
}
});
header.pack_start(&back_button);

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()));
let actions = gio::SimpleActionGroup::new();
actions.add_action(&{
let weak = self_weak.clone();
let action = gio::SimpleAction::new("sync", None);
action.connect_activate(move |_action, _param| {
if let Some(main) = weak.upgrade() {
main.borrow_mut().show_sync_view();
}
});
action
});
actions.add_action(&{
let weak = self_weak.clone();
let action = gio::SimpleAction::new("preferences", None);
Expand All @@ -405,27 +408,43 @@ impl MainWindow {
action
});
actions.add_action(&{
let weak = self_weak.clone();
let action = gio::SimpleAction::new("advanced", None);
let on_about = on_show_about.clone();
let action = gio::SimpleAction::new("about", None);
action.connect_activate(move |_action, _param| {
if let Some(main) = weak.upgrade() {
main.borrow_mut().show_advanced();
if let Some(cb) = &on_about {
cb();
}
});
action
});
actions.add_action(&{
let on_about = on_show_about.clone();
let action = gio::SimpleAction::new("about", None);
action.connect_activate(move |_action, _param| {
if let Some(cb) = &on_about {
cb();
let weak = self_weak.clone();
let store_for_theme = config_store.clone();
let current = config.general.color_scheme.clone();
let action = gio::SimpleAction::new_stateful(
"theme",
Some(glib::VariantTy::STRING),
&current.to_variant(),
);
action.connect_activate(move |action, parameter| {
let Some(value) = parameter.and_then(|value| value.str()) else {
return;
};
let scheme = value.to_string();
libadwaita::StyleManager::default().set_color_scheme(color_scheme_for(&scheme));
action.set_state(&scheme.to_variant());
if let Some(main) = weak.upgrade() {
let mut main = main.borrow_mut();
main.config.general.color_scheme = scheme.clone();
let mut persisted = store_for_theme.load().unwrap_or_default();
persisted.general.color_scheme = scheme;
let _ = store_for_theme.save(&persisted);
}
});
action
});
hamburger.insert_action_group("app", Some(&actions));
header.pack_start(&hamburger);
header.pack_end(&hamburger);

toolbar.add_top_bar(&header);

Expand Down Expand Up @@ -740,11 +759,6 @@ impl MainWindow {
self.show_settings_page(settings_page::GENERAL);
}

/// Open the in-app Advanced page (slides over the sync view).
pub fn show_advanced(&mut self) {
self.show_settings_page(settings_page::ADVANCED);
}

/// Ensure the settings view exists for the active account and slide to the
/// given page.
fn show_settings_page(&mut self, page: &str) {
Expand Down Expand Up @@ -1036,23 +1050,42 @@ 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, plus a
/// color-scheme radio section (System / Light / Dark).
fn hamburger_menu_model() -> gio::Menu {
let menu = gio::Menu::new();
let sync_item = gio::MenuItem::new(Some(t("Synchronization")), Some("app.sync"));
sync_item.set_icon(&gio::ThemedIcon::new("emblem-synchronizing-symbolic"));
menu.append_item(&sync_item);
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);
let advanced_item = gio::MenuItem::new(Some(t("Advanced")), Some("app.advanced"));
advanced_item.set_icon(&gio::ThemedIcon::new("applications-system-symbolic"));
menu.append_item(&advanced_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);

let theme_section = gio::Menu::new();
for (label, value) in [
(t("System"), "system"),
(t("Light"), "light"),
(t("Dark"), "dark"),
] {
let item = gio::MenuItem::new(Some(label), None);
item.set_action_and_target_value(Some("app.theme"), Some(&value.to_variant()));
theme_section.append_item(&item);
}
menu.append_section(None, &theme_section);
menu
}

/// Map the persisted color-scheme preference to a libadwaita color scheme.
///
/// Unknown values fall back to following the desktop (`system`).
pub fn color_scheme_for(preference: &str) -> libadwaita::ColorScheme {
match preference {
"light" => libadwaita::ColorScheme::ForceLight,
"dark" => libadwaita::ColorScheme::ForceDark,
_ => libadwaita::ColorScheme::Default,
}
}

/// Build the sidebar: the container, the accounts list and the Add Account
/// button.
fn build_sidebar() -> (gtk4::Box, gtk4::ListBox, gtk4::Button) {
Expand Down Expand Up @@ -1183,39 +1216,72 @@ mod tests {

set_locale(Locale::English);
let menu = hamburger_menu_model();
assert_eq!(menu.n_items(), 4);
let expected: [(&str, &str); 4] = [
("Synchronization", "app.sync"),
("Preferences", "app.preferences"),
("Advanced", "app.advanced"),
("About", "app.about"),
];
// Preferences + About, then the color-scheme section.
assert_eq!(menu.n_items(), 3);
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");
}

// The Spanish catalog covers every menu section (issue #10 renders
// the menu in-app, so the labels are user-visible on every launch).
// The color-scheme section carries radio items with the target value.
let theme_section = menu.item_link(2, gio::MENU_LINK_SECTION).expect("section");
let expected_theme: [(&str, &str); 3] =
[("System", "system"), ("Light", "light"), ("Dark", "dark")];
for (index, (label, target)) in expected_theme.iter().enumerate() {
let mut found_label = None;
let mut found_action = None;
let mut found_target = None;
let iter = theme_section.iterate_item_attributes(index as i32);
while let Some((key, value)) = iter.next() {
match key.as_str() {
"label" => found_label = value.str().map(str::to_string),
"action" => found_action = value.str().map(str::to_string),
"target" => found_target = value.str().map(str::to_string),
_ => {}
}
}
assert_eq!(found_label.as_deref(), Some(*label), "theme item {index}");
assert_eq!(
found_action.as_deref(),
Some("app.theme"),
"theme items must be radio-style with an action target"
);
assert_eq!(found_target.as_deref(), Some(*target), "theme item {index}");
}

// 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 labels: Vec<String> = (0..menu.n_items())
let labels: Vec<String> = (0..2)
.map(|index| item_attrs(&menu, index).label.expect("label"))
.collect();
assert_eq!(
labels,
vec![
"Sincronización".to_string(),
"Preferencias".to_string(),
"Avanzado".to_string(),
"Acerca de".to_string(),
]
vec!["Preferencias".to_string(), "Acerca de".to_string(),]
);
reset_locale();
}

#[test]
fn color_scheme_preference_maps_to_libadwaita() {
assert_eq!(color_scheme_for("system"), libadwaita::ColorScheme::Default);
assert_eq!(
color_scheme_for("light"),
libadwaita::ColorScheme::ForceLight
);
assert_eq!(color_scheme_for("dark"), libadwaita::ColorScheme::ForceDark);
// Unknown values follow the desktop.
assert_eq!(
color_scheme_for("nonsense"),
libadwaita::ColorScheme::Default
);
}

#[test]
fn outer_stack_slides_the_settings_view_over_sync() {
// Must run through the shared GTK test worker (see
Expand Down Expand Up @@ -1272,11 +1338,6 @@ mod tests {
window.root_stack.visible_child_name().as_deref(),
Some("settings")
);
window.show_advanced();
assert_eq!(
window.root_stack.visible_child_name().as_deref(),
Some("settings")
);

// Synchronization slides back without dropping the view.
window.show_sync_view();
Expand Down Expand Up @@ -1365,7 +1426,6 @@ mod tests {
Weak::new(),
);
window.show_preferences();
window.show_advanced();
assert!(window.settings_view.is_none());
assert!(window.root_stack.child_by_name("settings").is_none());
assert_eq!(
Expand Down
1 change: 1 addition & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod activity;
pub mod conflict_resolver;
pub mod folder_status;
pub mod main_window;
pub use main_window::color_scheme_for;
pub mod settings;
pub mod setup;
pub mod tray;
Expand Down
Loading
Loading