From 93a48a7e511aa048d039a976fd5a15fb156a192a Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Sun, 26 Jul 2026 21:28:42 -0300 Subject: [PATCH] feat: GNOME-style welcome, terminal launcher config, and UX polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Welcome page (GNOME Initial Setup style): - edge-to-edge banner (no margins or rounded frame) and a greeting carousel cycling "Welcome" through the pinned languages every 5 s, filtered by font coverage, mirroring GisWelcomeWidget Terminal launcher: - new [terminal] section in sirius.toml (command, show_button); the header-bar button is hidden by default and Ctrl+Shift+P always opens the configured terminal Time zone page: - drop the libgweather dependency; the chooser is backed only by the tzdb tables, like GNOME Initial Setup - the selected city shows as a permanent label above the pin (name, zone, UTC offset, local time) instead of a hover tooltip Language & keyboard pages: - selection now uses the default Adwaita selected-row background instead of a check mark; the "More…" expander row is gone (the list scrolls) Network page: - rows connect on activation (no oversized Connect button); rescan is a flat refresh icon pinned to the group header, outside the scroll area so the scrollbar never covers it Storage page: - disk picker rows use real grouped GtkCheckButton radios instead of a custom check mark Chooser lists (language, keyboard, network): - suppress the GTK undershoot shadow that grayed the card's bottom edge and corners whenever the list could scroll i18n: - set_ui_language now also setlocale()s the chosen locale: on the live ISO the process starts in the C locale (empty service environment), where glibc gettext ignores LANGUAGE entirely - language page logo capped with GtkImage pixel_size instead of a GtkPicture that let the intrinsic PNG size win Tests: - interactive GTK tests now run on a single GTK-owning thread (GTK can only be initialized from one thread); new assertions for arrow centering, welcome banner/greetings, pin label, C-locale language switch, and radio-less timezone search --- .github/workflows/ci.yml | 4 +- CONTRIBUTING.md | 2 +- Cargo.lock | 26 --- Cargo.toml | 1 - INSTALL.md | 10 + crates/sirius-app/Cargo.toml | 1 - crates/sirius-app/src/app.rs | 118 +++++++++- crates/sirius-app/src/app/bootstrap.rs | 2 + crates/sirius-app/src/i18n.rs | 43 +++- crates/sirius-app/src/pages/keyboard.rs | 52 ++--- crates/sirius-app/src/pages/language.rs | 107 ++++----- .../sirius-app/src/pages/language/locale.rs | 7 +- crates/sirius-app/src/pages/mod.rs | 41 ++++ crates/sirius-app/src/pages/network.rs | 45 ++-- .../sirius-app/src/pages/storage/page_view.rs | 31 ++- crates/sirius-app/src/pages/timezone.rs | 208 ++++++++++-------- crates/sirius-app/src/pages/welcome.rs | 187 +++++++++++++--- crates/sirius-app/tests/language_switch.rs | 9 + crates/sirius-diag/src/config.rs | 45 ++++ crates/sirius-installer/Cargo.toml | 1 - data/sirius.toml | 6 + data/style.css | 23 +- 22 files changed, 671 insertions(+), 298 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e3f08..bf25458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Install build dependencies run: | - dnf install -y git gcc clang gtk4-devel libadwaita-devel libgweather-devel \ + dnf install -y git gcc clang gtk4-devel libadwaita-devel \ gnome-desktop4-devel fontconfig-devel lcms2-devel libseccomp-devel \ gettext glibc-all-langpacks pkgconf-pkg-config rust cargo clippy rustfmt @@ -49,7 +49,7 @@ jobs: steps: - name: Install build dependencies run: | - dnf install -y git gcc clang gtk4-devel libadwaita-devel libgweather-devel \ + dnf install -y git gcc clang gtk4-devel libadwaita-devel \ gnome-desktop4-devel fontconfig-devel lcms2-devel libseccomp-devel \ gettext glibc-all-langpacks pkgconf-pkg-config rust cargo rpm-build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69e37c6..92d10ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Install system dependencies on Fedora or inside the project toolbox: ```sh sudo dnf install -y \ rust cargo pkgconf-pkg-config \ - gtk4-devel libadwaita-devel libgweather-devel gnome-desktop4-devel gettext \ + gtk4-devel libadwaita-devel gnome-desktop4-devel gettext \ lcms2-devel fontconfig-devel libseccomp-devel glycin-loaders bubblewrap \ glibc-all-langpacks ``` diff --git a/Cargo.lock b/Cargo.lock index 32d8202..0f3e133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1802,19 +1802,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "gweather-sys" -version = "4.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd48d8a64d00703dc2a77efb2284c07192a1345d0162e0bc15488b55d577f0e" -dependencies = [ - "gio-sys 0.22.8", - "glib-sys 0.22.8", - "gobject-sys 0.22.6", - "libc", - "system-deps", -] - [[package]] name = "half" version = "2.7.1" @@ -2145,18 +2132,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libgweather" -version = "4.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dd39da07edc7e068f1b02da95d5813030343cfd94ae1f132d25695ba8e6c9f" -dependencies = [ - "gio 0.22.8", - "glib 0.22.8", - "gweather-sys", - "libc", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3254,7 +3229,6 @@ dependencies = [ "gio 0.22.8", "glycin", "libadwaita", - "libgweather", "relm4", "sirius-backend", "sirius-core", diff --git a/Cargo.toml b/Cargo.toml index 9b60865..b683772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,6 @@ lsblk = "0.6.1" uuid = { version = "1", features = ["v4"] } zbus = "5" zvariant = "5" -libgweather = "4" # SVG decoding for the timezone map; driven on a dedicated thread through # async-io (glycin's native runtime), never on the glib main context. Pinned # to 3.x because its loader protocol (COMPAT_VERSION 2) matches the diff --git a/INSTALL.md b/INSTALL.md index 700e359..b50743d 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -64,6 +64,16 @@ warn = ["secure_boot", "network", "virt"] min_ram_gib = 2 ``` +Optional terminal launcher, also in `/etc/sirius/sirius.toml`. The +Ctrl+Shift+P shortcut always works; the header-bar button stays hidden unless +`show_button` is enabled: + +```toml +[terminal] +command = "ptyxis" +show_button = false +``` + The canonical page id for disk selection and automatic/manual partitioning is `storage`. Older configurations that list `disk`, `partition`, or `manual_partition` are migrated in memory to one `storage` page. The `network` diff --git a/crates/sirius-app/Cargo.toml b/crates/sirius-app/Cargo.toml index 9a1f427..4207e11 100644 --- a/crates/sirius-app/Cargo.toml +++ b/crates/sirius-app/Cargo.toml @@ -12,7 +12,6 @@ sirius-diag = { workspace = true } relm4 = { workspace = true } gettext-rs = { workspace = true } adw = { workspace = true } -libgweather = { workspace = true } glycin = { workspace = true } async-io = { workspace = true } futures-channel = "0.3" diff --git a/crates/sirius-app/src/app.rs b/crates/sirius-app/src/app.rs index 43c8a9e..a3c3122 100644 --- a/crates/sirius-app/src/app.rs +++ b/crates/sirius-app/src/app.rs @@ -19,6 +19,8 @@ pub struct AppModel { carousel: Option, page_widgets: std::collections::HashMap, window: Option, + /// Command line for the terminal launcher (button + Ctrl+Shift+P). + terminal_command: String, } #[derive(Debug)] @@ -50,9 +52,11 @@ impl SimpleComponent for AppModel { add_top_bar = &adw::HeaderBar { set_show_end_title_buttons: false, + #[name = "terminal_button"] pack_start = >k::Button { set_icon_name: "utilities-terminal-symbolic", add_css_class: "flat", + set_visible: false, #[watch] set_tooltip_text: Some(gettext("Open terminal").as_str()), connect_clicked => AppMsg::OpenTerminal, @@ -136,9 +140,32 @@ impl SimpleComponent for AppModel { carousel: None, page_widgets: std::collections::HashMap::new(), window: None, + terminal_command: bootstrap.terminal.command.clone(), }; let widgets = view_output!(); + widgets + .terminal_button + .set_visible(bootstrap.terminal.show_button); + + // Ctrl+Shift+P opens the configured terminal even with the + // header-bar button hidden (the default). + { + let command = bootstrap.terminal.command.clone(); + let keys = gtk::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, mods| { + let wanted = matches!(key, gtk::gdk::Key::P | gtk::gdk::Key::p) + && mods.contains( + gtk::gdk::ModifierType::CONTROL_MASK | gtk::gdk::ModifierType::SHIFT_MASK, + ); + if wanted { + open_terminal(&command); + return gtk::glib::Propagation::Stop; + } + gtk::glib::Propagation::Proceed + }); + root.add_controller(keys); + } for id in &pages_order { if let Some(w) = model.pages.widget(id) { @@ -152,8 +179,13 @@ impl SimpleComponent for AppModel { // Keep page content clear of the overlay navigation arrows and // visually centered at every step of the carousel. The progress // page shows neither arrow (Back hides once the install starts, - // Next is hidden on it), so the margins would be dead space. - let side_margin = if id == "progress" { 0 } else { 72 }; + // Next is hidden on it) and the welcome page's banner runs + // edge-to-edge, so the margins would be dead space on both. + let side_margin = if matches!(id.as_str(), "progress" | "welcome") { + 0 + } else { + 72 + }; w.set_margin_start(side_margin); w.set_margin_end(side_margin); widgets.carousel.append(&w); @@ -199,7 +231,7 @@ impl SimpleComponent for AppModel { match msg { AppMsg::Page(PageOutput::RequestInstall) => self.confirm_install(&sender), AppMsg::Page(out) => self.apply_page_output(out), - AppMsg::OpenTerminal => Self::open_terminal(), + AppMsg::OpenTerminal => open_terminal(&self.terminal_command), AppMsg::Next => { // Leaving the summary erases the disk: require explicit confirmation. if self.state.current_page() == "summary" { @@ -256,13 +288,19 @@ impl SimpleComponent for AppModel { } } -impl AppModel { - fn open_terminal() { - if let Err(err) = std::process::Command::new("ptyxis").spawn() { - tracing::error!(?err, "failed to launch Ptyxis"); - } +/// Launch the configured terminal command (program plus arguments, split on +/// whitespace — no shell quoting). +fn open_terminal(command: &str) { + let mut parts = command.split_whitespace(); + let Some(program) = parts.next() else { + return; + }; + if let Err(err) = std::process::Command::new(program).args(parts).spawn() { + tracing::error!(?err, command, "failed to launch the configured terminal"); } +} +impl AppModel { /// Modal "this will erase the disk" gate before leaving the summary page. fn confirm_install(&self, sender: &ComponentSender) { let config = self.state.config(); @@ -328,3 +366,67 @@ impl AppModel { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // Interactive: the overlay Back/Next arrows must keep their icons + // centered inside the 44px circle. + #[test] + fn navigation_arrow_icons_stay_centered() { + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + eprintln!("skipping interactive test: no display available"); + return; + } + crate::pages::testutil::run_on_gtk_thread(navigation_arrows_interactive); + } + + fn navigation_arrows_interactive() { + crate::style::load(); + + let back = gtk::Button::from_icon_name("go-previous-symbolic"); + back.add_css_class("navigation-arrow"); + back.set_halign(gtk::Align::Start); + back.set_valign(gtk::Align::Center); + let next = gtk::Button::from_icon_name("go-next-symbolic"); + next.add_css_class("navigation-arrow"); + next.add_css_class("suggested-action"); + next.set_halign(gtk::Align::End); + next.set_valign(gtk::Align::Center); + let row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + row.append(&back); + row.append(&next); + let window = adw::Window::new(); + window.set_content(Some(&row)); + window.present(); + + let context = gtk::glib::MainContext::default(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800); + while std::time::Instant::now() < deadline { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + for button in [&back, &next] { + assert_eq!( + (button.width(), button.height()), + (44, 44), + "navigation-arrow must render as a 44px circle" + ); + let image = button.first_child().expect("icon child"); + let bounds = image + .compute_bounds(button) + .expect("icon bounds inside the button"); + let center_x = f64::from(bounds.x()) + f64::from(bounds.width()) / 2.0; + let center_y = f64::from(bounds.y()) + f64::from(bounds.height()) / 2.0; + assert!( + (center_x - 22.0).abs() < 1.5 && (center_y - 22.0).abs() < 1.5, + "icon must be centered in the 44px button, got center ({center_x:.1}, {center_y:.1})" + ); + } + window.close(); + } +} diff --git a/crates/sirius-app/src/app/bootstrap.rs b/crates/sirius-app/src/app/bootstrap.rs index 83300ec..6266f21 100644 --- a/crates/sirius-app/src/app/bootstrap.rs +++ b/crates/sirius-app/src/app/bootstrap.rs @@ -14,6 +14,7 @@ pub(super) struct Bootstrap { pub uefi: bool, pub bentos: Vec, pub branding: Branding, + pub terminal: sirius_diag::config::TerminalConfig, } pub(super) fn load() -> Bootstrap { @@ -50,5 +51,6 @@ pub(super) fn load() -> Bootstrap { uefi: Path::new("/sys/firmware/efi").exists(), bentos, branding, + terminal: config.terminal, } } diff --git a/crates/sirius-app/src/i18n.rs b/crates/sirius-app/src/i18n.rs index 092e8df..501ef51 100644 --- a/crates/sirius-app/src/i18n.rs +++ b/crates/sirius-app/src/i18n.rs @@ -9,9 +9,17 @@ unsafe extern "C" { /// Select the language used by subsequent GNU gettext lookups. /// -/// GNU gettext caches the last loaded catalog. Updating `LANGUAGE` alone can -/// therefore leave the previous translation active; advancing its catalog -/// generation makes the new preference visible immediately. +/// Two things are needed for a switch to take effect everywhere: +/// +/// - `LANGUAGE` plus a catalog-generation bump: GNU gettext caches the last +/// loaded catalog, so advancing the generation makes the new preference +/// visible immediately. +/// - `setlocale(LC_ALL, )`: when the installer starts with an empty +/// environment (the live session's systemd service), the process sits in +/// the C locale and glibc gettext ignores `LANGUAGE` entirely, always +/// returning the English msgids. Pointing `LC_ALL` at the chosen locale +/// makes the switch work from any starting point — it requires the locale +/// to be installed on the system (e.g. `glibc-all-langpacks`). pub fn set_ui_language(locale: &str) { // SAFETY: Sirius changes the process-wide gettext language only from the // single-threaded GTK main loop, before installation workers are started. @@ -19,4 +27,33 @@ pub fn set_ui_language(locale: &str) { std::env::set_var("LANGUAGE", locale); GETTEXT_CATALOG_GENERATION = GETTEXT_CATALOG_GENERATION.wrapping_add(1); } + gettextrs::setlocale(gettextrs::LocaleCategory::LcAll, with_utf8_codeset(locale)); +} + +/// `pt_BR` → `pt_BR.UTF-8`, keeping an existing codeset or `@modifier` in +/// place (`sr_RS@latin` → `sr_RS.UTF-8@latin`). +fn with_utf8_codeset(locale: &str) -> String { + let (before_modifier, modifier) = locale.split_once('@').unwrap_or((locale, "")); + let base = before_modifier.split('.').next().unwrap_or(before_modifier); + if modifier.is_empty() { + format!("{base}.UTF-8") + } else { + format!("{base}.UTF-8@{modifier}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adds_a_utf8_codeset_without_losing_the_modifier() { + assert_eq!(with_utf8_codeset("pt_BR"), "pt_BR.UTF-8"); + assert_eq!(with_utf8_codeset("pt_BR.UTF-8"), "pt_BR.UTF-8"); + assert_eq!(with_utf8_codeset("sr_RS@latin"), "sr_RS.UTF-8@latin"); + assert_eq!( + with_utf8_codeset("ca_ES.UTF-8@valencia"), + "ca_ES.UTF-8@valencia" + ); + } } diff --git a/crates/sirius-app/src/pages/keyboard.rs b/crates/sirius-app/src/pages/keyboard.rs index 4efb85d..bb46e1a 100644 --- a/crates/sirius-app/src/pages/keyboard.rs +++ b/crates/sirius-app/src/pages/keyboard.rs @@ -22,14 +22,12 @@ pub struct KeyboardPage { test_entry: gtk::Entry, input_settings: gtk::gio::Settings, layouts: Vec, - /// Locale-relevant layout ids shown before the "More…" row is expanded. + /// Locale-relevant layout ids, listed first (the rest follows — the list + /// scrolls, so there is no "More…" expander). initial_ids: Vec, /// Layout indices currently shown as rows, in row order. visible: Vec, - /// Whether a "More…" row is appended after the visible layouts. - more_row: bool, selected: usize, - showing_extra: bool, user_selected: bool, } @@ -76,9 +74,7 @@ impl SimpleComponent for KeyboardPage { layouts, initial_ids, visible: Vec::new(), - more_row: false, selected, - showing_extra: false, user_selected: false, }; model.rebuild_list(""); @@ -94,10 +90,7 @@ impl SimpleComponent for KeyboardPage { match msg { KeyboardMsg::SearchChanged(query) => self.rebuild_list(&query), KeyboardMsg::RowActivated(index) => { - if self.more_row && index == self.visible.len() { - self.showing_extra = true; - self.rebuild_list(&self.search.text()); - } else if let Some(layout_index) = self.visible.get(index).copied() { + if let Some(layout_index) = self.visible.get(index).copied() { if layout_index == self.selected { // Re-activating the selected row confirms the choice // (CcInputChooser::confirm_choice). @@ -157,46 +150,28 @@ impl KeyboardPage { order.sort_by_key(|index| !self.is_initial(*index)); self.visible.clear(); - self.more_row = false; for index in order { let layout = &self.layouts[index]; - let show = if searching { - layout.search_text.contains(&query) - } else { - self.is_initial(index) || self.showing_extra - }; - if !show { + if searching && !layout.search_text.contains(&query) { continue; } let row = adw::ActionRow::new(); row.set_title(&layout.name); row.set_activatable(true); - row.add_suffix(&super::choice_list::selected_indicator( - index == self.selected, - )); self.list.append(&row); self.visible.push(index); } - let has_extra = self - .layouts + // The selection shows as the row's default Adwaita selected + // background — no check mark. + if let Some(position) = self + .visible .iter() - .enumerate() - .any(|(index, _)| !self.is_initial(index)); - if !searching && !self.showing_extra && has_extra { - let arrow = gtk::Image::from_icon_name("view-more-symbolic"); - arrow.add_css_class("dim-label"); - arrow.set_hexpand(true); - arrow.set_halign(gtk::Align::Center); - arrow.set_margin_top(12); - arrow.set_margin_bottom(12); - let row = gtk::ListBoxRow::new(); - row.set_activatable(true); - row.set_tooltip_text(Some(&gettext("More…"))); - row.set_child(Some(&arrow)); - self.list.append(&row); - self.more_row = true; + .position(|index| *index == self.selected) + && let Some(row) = self.list.row_at_index(position as i32) + { + self.list.select_row(Some(&row)); } } @@ -250,7 +225,7 @@ fn keyboard_content( choices.append(&search); let list = gtk::ListBox::new(); - list.set_selection_mode(gtk::SelectionMode::None); + list.set_selection_mode(gtk::SelectionMode::Single); list.add_css_class("boxed-list"); list.set_valign(gtk::Align::Start); let no_results = gtk::Label::new(Some(&gettext("No inputs found"))); @@ -269,6 +244,7 @@ fn keyboard_content( scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); scroll.set_min_content_height(190); scroll.set_max_content_height(250); + scroll.add_css_class("chooser-scroll"); scroll.set_child(Some(&list)); choices.append(&scroll); diff --git a/crates/sirius-app/src/pages/language.rs b/crates/sirius-app/src/pages/language.rs index 8f3a177..b6b61ff 100644 --- a/crates/sirius-app/src/pages/language.rs +++ b/crates/sirius-app/src/pages/language.rs @@ -3,7 +3,7 @@ //! "More…" row revealing every other locale, and a checkmark on the active //! one. Locale data comes from GNOME Desktop (see `locale`). -mod locale; +pub(crate) mod locale; use super::PageOutput; use gettextrs::gettext; @@ -21,10 +21,7 @@ pub struct LanguagePage { locales: Vec, /// Locale indices currently shown as rows, in row order. visible: Vec, - /// Whether a "More…" row is appended after the visible locales. - more_row: bool, selected: usize, - showing_extra: bool, } #[derive(Debug)] @@ -71,9 +68,7 @@ impl SimpleComponent for LanguagePage { no_results, locales, visible: Vec::new(), - more_row: false, selected, - showing_extra: false, }; model.rebuild_list(""); model.emit_selection(&sender); @@ -88,10 +83,7 @@ impl SimpleComponent for LanguagePage { match msg { LanguageMsg::SearchChanged(query) => self.rebuild_list(&query), LanguageMsg::RowActivated(index) => { - if self.more_row && index == self.visible.len() { - self.showing_extra = true; - self.rebuild_list(&self.search.text()); - } else if let Some(locale_index) = self.visible.get(index).copied() { + if let Some(locale_index) = self.visible.get(index).copied() { self.selected = locale_index; self.emit_selection(&sender); self.rebuild_list(&self.search.text()); @@ -123,9 +115,7 @@ impl LanguagePage { self.list.remove(&child); } - let (visible, more_row) = visible_indices(&self.locales, query, self.showing_extra); - self.more_row = more_row; - self.visible = visible; + self.visible = visible_indices(&self.locales, query); for index in self.visible.clone() { let entry = &self.locales[index]; @@ -141,10 +131,6 @@ impl LanguagePage { name.set_max_width_chars(30); row_box.append(&name); - row_box.append(&super::choice_list::selected_indicator( - index == self.selected, - )); - if let Some(country) = &entry.country_native { let label = gtk::Label::new(Some(country)); label.add_css_class("dim-label"); @@ -162,18 +148,15 @@ impl LanguagePage { self.list.append(&row); } - if self.more_row { - let arrow = gtk::Image::from_icon_name("view-more-symbolic"); - arrow.add_css_class("dim-label"); - arrow.set_hexpand(true); - arrow.set_halign(gtk::Align::Center); - arrow.set_margin_top(12); - arrow.set_margin_bottom(12); - let row = gtk::ListBoxRow::new(); - row.set_activatable(true); - row.set_tooltip_text(Some(&gettext("More…"))); - row.set_child(Some(&arrow)); - self.list.append(&row); + // The selection shows as the row's default Adwaita selected + // background — no check mark. + if let Some(position) = self + .visible + .iter() + .position(|index| *index == self.selected) + && let Some(row) = self.list.row_at_index(position as i32) + { + self.list.select_row(Some(&row)); } } @@ -186,31 +169,18 @@ impl LanguagePage { } } -/// Row set shown for a query, mirroring `language_visible`: while searching, -/// every matching locale (initial or extra) shows and the "More…" row hides; -/// otherwise only the pinned entries show until the extras are expanded. -/// Returns the visible locale indices plus whether a "More…" row follows. -fn visible_indices( - locales: &[locale::LocaleEntry], - query: &str, - showing_extra: bool, -) -> (Vec, bool) { +/// Row set shown for a query: while searching, every matching locale; without +/// a query, the full list (pinned languages first, then the rest — the list +/// scrolls, so there is no "More…" expander). +fn visible_indices(locales: &[locale::LocaleEntry], query: &str) -> Vec { let query = locale::normalize(query); let searching = !query.is_empty(); - let visible = locales + locales .iter() .enumerate() - .filter(|(_, entry)| { - if searching { - entry.matches(&query) - } else { - entry.is_initial || showing_extra - } - }) + .filter(|(_, entry)| !searching || entry.matches(&query)) .map(|(index, _)| index) - .collect(); - let more_row = !searching && !showing_extra && locales.iter().any(|entry| !entry.is_initial); - (visible, more_row) + .collect() } fn apply_header(root: &adw::StatusPage) { @@ -255,7 +225,7 @@ fn language_content( choices.append(&search); let list = gtk::ListBox::new(); - list.set_selection_mode(gtk::SelectionMode::None); + list.set_selection_mode(gtk::SelectionMode::Single); list.add_css_class("boxed-list"); list.set_valign(gtk::Align::Start); let no_results = gtk::Label::new(Some(&gettext("No languages found"))); @@ -275,6 +245,7 @@ fn language_content( scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); scroll.set_min_content_height(250); scroll.set_max_content_height(300); + scroll.add_css_class("chooser-scroll"); scroll.set_child(Some(&list)); choices.append(&scroll); content.append(&choices); @@ -292,11 +263,12 @@ fn branding_view(branding: &Branding) -> gtk::Box { .as_deref() .filter(|path| Path::new(path).is_file()) { - let picture = gtk::Picture::for_filename(path); - picture.set_width_request(220); - picture.set_height_request(160); - picture.set_content_fit(gtk::ContentFit::Contain); - column.append(&picture); + // A Picture would let the logo's intrinsic pixel size win over the + // request and grow with the window; Image + pixel_size caps it like + // the themed-icon fallback below. + let image = gtk::Image::from_file(path); + image.set_pixel_size(128); + column.append(&image); } else { let image = gtk::Image::from_icon_name(branding.icon.as_deref().unwrap_or("starred-symbolic")); @@ -316,28 +288,29 @@ mod tests { use super::*; #[test] - fn without_a_query_only_pinned_languages_show_behind_more() { + fn without_a_query_everything_shows_pinned_first() { let locales = locale::load(); - let (visible, more_row) = visible_indices(&locales, "", false); - assert!(!visible.is_empty()); - assert!(visible.iter().all(|index| locales[*index].is_initial)); - assert!(more_row, "the More… row must gate the extra locales"); - - let (expanded, more_row) = visible_indices(&locales, "", true); - assert!(expanded.len() > visible.len()); - assert!(!more_row); + let visible = visible_indices(&locales, ""); + assert_eq!(visible.len(), locales.len(), "no More… gate: show all"); + let first_extra = visible.iter().position(|index| !locales[*index].is_initial); + if let Some(first_extra) = first_extra { + assert!( + visible[..first_extra] + .iter() + .all(|index| locales[*index].is_initial) + ); + } } #[test] - fn searching_reaches_extra_locales_and_hides_more() { + fn searching_reaches_extra_locales() { let locales = locale::load(); let extra = locales .iter() .find(|entry| !entry.is_initial) .expect("extra locales must exist"); let term = locale::normalize(extra.name_native.split_whitespace().next().unwrap()); - let (visible, more_row) = visible_indices(&locales, &term, false); + let visible = visible_indices(&locales, &term); assert!(visible.iter().any(|index| locales[*index].id == extra.id)); - assert!(!more_row); } } diff --git a/crates/sirius-app/src/pages/language/locale.rs b/crates/sirius-app/src/pages/language/locale.rs index 729edbd..c87e1f6 100644 --- a/crates/sirius-app/src/pages/language/locale.rs +++ b/crates/sirius-app/src/pages/language/locale.rs @@ -19,6 +19,11 @@ const INITIAL_LOCALES: &[&str] = &[ "en_US", "pt_BR", "de_DE", "fr_FR", "es_ES", "zh_CN", "ja_JP", "ru_RU", "ar_EG", ]; +/// The pinned locale ids, shared with the welcome page's greeting carousel. +pub(crate) fn initial_locale_ids() -> &'static [&'static str] { + INITIAL_LOCALES +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct LocaleEntry { /// Locale id without codeset, e.g. `pt_BR` — the `SetLocale` wire format. @@ -275,7 +280,7 @@ fn country_from_code(code: &str, translation: Option<&str>) -> Option { /// Port of `cc_common_language_has_font`: when fontconfig does not know the /// language we assume it renders; otherwise some installed font must cover /// its charset. -fn has_font(language: &str) -> bool { +pub(crate) fn has_font(language: &str) -> bool { let Ok(c_language) = CString::new(language) else { return false; }; diff --git a/crates/sirius-app/src/pages/mod.rs b/crates/sirius-app/src/pages/mod.rs index 24d7c9c..c8c15af 100644 --- a/crates/sirius-app/src/pages/mod.rs +++ b/crates/sirius-app/src/pages/mod.rs @@ -23,6 +23,47 @@ use sirius_core::{InstallType, PartitionPlan, UserAccount}; /// that trigger that one-time setup, so concurrent first use cannot race it. pub(crate) static GNOME_DESKTOP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// Single GTK-owning thread for interactive tests. GTK can only be +/// initialized from one thread, but the Rust harness gives every test its +/// own thread — so interactive tests hand their body to this one. +#[cfg(test)] +pub(crate) mod testutil { + use std::sync::OnceLock; + use std::sync::mpsc::{Sender, channel}; + + static GTK_THREAD: OnceLock>> = OnceLock::new(); + + fn spawn() -> Sender> { + let (sender, receiver) = channel::>(); + std::thread::spawn(move || { + relm4::gtk::init().expect("gtk init"); + relm4::adw::init().expect("adw init"); + while let Ok(job) = receiver.recv() { + job(); + } + }); + sender + } + + /// Run `test` on the thread that owns GTK for this test binary, blocking + /// until it finishes. Panics inside the body propagate to the caller. + pub(crate) fn run_on_gtk_thread(test: impl FnOnce() + Send + 'static) { + let (done_sender, done_receiver) = channel(); + GTK_THREAD + .get_or_init(spawn) + .send(Box::new(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(test)); + done_sender.send(result).ok(); + })) + .expect("gtk test thread alive"); + match done_receiver.recv() { + Ok(Ok(())) => {} + Ok(Err(payload)) => std::panic::resume_unwind(payload), + Err(_) => panic!("gtk test thread died"), + } + } +} + /// Set a status page's translated header. Pages call this both in `init` and /// on every `update_view`: gettext resolves at call time, so re-applying on /// the `Retranslate` nudge is what re-renders the header in the new language. diff --git a/crates/sirius-app/src/pages/network.rs b/crates/sirius-app/src/pages/network.rs index a286844..535bb54 100644 --- a/crates/sirius-app/src/pages/network.rs +++ b/crates/sirius-app/src/pages/network.rs @@ -135,8 +135,27 @@ impl SimpleComponent for NetworkPage { heading.set_halign(gtk::Align::Start); choices.append(&heading); + // The group header stays outside the scroll area: only the network + // list scrolls, so the scrollbar never covers the rescan icon. + let header = gtk::Box::new(gtk::Orientation::Horizontal, 0); + let title = gtk::Label::new(Some(&gettext("Available Wi-Fi networks"))); + title.add_css_class("heading"); + title.set_halign(gtk::Align::Start); + title.set_hexpand(true); + header.append(&title); + let refresh = gtk::Button::from_icon_name("view-refresh-symbolic"); + refresh.add_css_class("flat"); + refresh.set_valign(gtk::Align::Center); + refresh.set_tooltip_text(Some(&gettext("Scan again"))); + refresh.set_sensitive(!self.loading && self.connecting.is_none()); + { + let sender = sender.clone(); + refresh.connect_clicked(move |_| sender.input(NetworkMsg::Refresh)); + } + header.append(&refresh); + choices.append(&header); + let group = adw::PreferencesGroup::new(); - group.set_title(&gettext("Available Wi-Fi networks")); if self.loading { let row = adw::ActionRow::new(); row.set_title(&gettext("Looking for networks…")); @@ -150,6 +169,15 @@ impl SimpleComponent for NetworkPage { row.set_title(&network.ssid); row.set_subtitle(&security_label(network.security)); row.add_prefix(>k::Image::from_icon_name(signal_icon(network.strength))); + // Rows connect on activation, like the GNOME Wi-Fi panel — no + // oversized Connect button per row. + row.set_activatable( + !network.active + && network.security != WifiSecurity::Unsupported + && self.connecting.is_none(), + ); + let s = sender.clone(); + row.connect_activated(move |_| s.input(NetworkMsg::Select(index))); if network.active { let connected = gtk::Label::new(Some(&gettext("Connected"))); connected.add_css_class("accent"); @@ -158,15 +186,6 @@ impl SimpleComponent for NetworkPage { let spinner = gtk::Spinner::new(); spinner.start(); row.add_suffix(&spinner); - } else { - let button = gtk::Button::with_label(&gettext("Connect")); - button.set_sensitive( - network.security != WifiSecurity::Unsupported && self.connecting.is_none(), - ); - let s = sender.clone(); - button.connect_clicked(move |_| s.input(NetworkMsg::Select(index))); - row.add_suffix(&button); - row.set_activatable_widget(Some(&button)); } group.add(&row); } @@ -174,6 +193,7 @@ impl SimpleComponent for NetworkPage { scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); scroll.set_min_content_height(250); scroll.set_max_content_height(300); + scroll.add_css_class("chooser-scroll"); scroll.set_child(Some(&group)); choices.append(&scroll); if let Some(error) = &self.error { @@ -182,11 +202,6 @@ impl SimpleComponent for NetworkPage { label.set_wrap(true); choices.append(&label); } - let refresh = gtk::Button::with_label(&gettext("Scan again")); - refresh.set_halign(gtk::Align::Center); - refresh.set_sensitive(!self.loading && self.connecting.is_none()); - refresh.connect_clicked(move |_| sender.input(NetworkMsg::Refresh)); - choices.append(&refresh); content.append(&choices); widgets.root.set_child(Some(&content)); } diff --git a/crates/sirius-app/src/pages/storage/page_view.rs b/crates/sirius-app/src/pages/storage/page_view.rs index 6994287..36bf964 100644 --- a/crates/sirius-app/src/pages/storage/page_view.rs +++ b/crates/sirius-app/src/pages/storage/page_view.rs @@ -132,6 +132,7 @@ fn disk_selector( return group; } + let mut radio_group: Option = None; for &index in &available { let disk = &disks[index]; let row = adw::ActionRow::new(); @@ -143,15 +144,29 @@ fn disk_selector( disk.table_type )); row.add_prefix(>k::Image::from_icon_name("drive-harddisk-symbolic")); - row.add_suffix(&super::super::choice_list::selected_indicator( - selected == Some(index), - )); - row.set_activatable(true); - let page_sender = sender.clone(); - row.connect_activated(move |_| { - page_sender.input(StorageMsg::Selected(index)); - }); + // Real grouped check buttons (GTK radio semantics), not a custom + // check mark: the row toggles the button, the button selects the disk. + let radio = gtk::CheckButton::new(); + if let Some(leader) = &radio_group { + radio.set_group(Some(leader)); + } else { + radio_group = Some(radio.clone()); + } + // Set the current state before connecting `toggled`, so rebuilding the + // view never emits a redundant selection. + radio.set_active(selected == Some(index)); + { + let page_sender = sender.clone(); + radio.connect_toggled(move |radio| { + if radio.is_active() { + page_sender.input(StorageMsg::Selected(index)); + } + }); + } + row.add_suffix(&radio); + row.set_activatable_widget(Some(&radio)); + group.add(&row); } diff --git a/crates/sirius-app/src/pages/timezone.rs b/crates/sirius-app/src/pages/timezone.rs index 3836711..ec0169f 100644 --- a/crates/sirius-app/src/pages/timezone.rs +++ b/crates/sirius-app/src/pages/timezone.rs @@ -2,7 +2,6 @@ use super::PageOutput; use gettextrs::gettext; -use libgweather as gweather; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, adw, gtk}; use std::cell::Cell; @@ -61,6 +60,7 @@ pub struct TimezonePage { results_popover: gtk::Popover, map: gtk::Picture, pin: gtk::Picture, + pin_label: gtk::Label, band: gtk::Box, locations: Vec, filtered: Vec, @@ -238,13 +238,18 @@ impl SimpleComponent for TimezonePage { } pin.set_halign(gtk::Align::Start); pin.set_valign(gtk::Align::Start); - // The pin shows the selected city as a tooltip, so it must be - // hoverable; it sits exactly on the selected city, so the handful of - // pixels it keeps away from the map's click gesture cost nothing. - pin.set_can_target(true); - pin.set_has_tooltip(true); + // The selected city shows as a permanent label above the pin instead + // of a hover tooltip. + pin.set_can_target(false); pin.set_visible(pin_asset.is_some()); + let pin_label = gtk::Label::new(None); + pin_label.add_css_class("timezone-pin-label"); + pin_label.set_justify(gtk::Justification::Center); + pin_label.set_halign(gtk::Align::Start); + pin_label.set_valign(gtk::Align::Start); + pin_label.set_can_target(false); + let band = gtk::Box::new(gtk::Orientation::Vertical, 0); band.add_css_class("timezone-band"); band.set_halign(gtk::Align::Start); @@ -256,6 +261,7 @@ impl SimpleComponent for TimezonePage { // GTK4 way to react to allocation changes (first map + resizes). { let pin = pin.clone(); + let pin_label = pin_label.clone(); let band = band.clone(); let selected_point = selected_point.clone(); let band_meridian = band_meridian.clone(); @@ -264,6 +270,7 @@ impl SimpleComponent for TimezonePage { let height = f64::from(map.height()); if width > 1.0 && height > 1.0 { position_pin(&pin, selected_point.get(), width, height); + position_pin_label(&pin_label, selected_point.get(), width, height); position_band(&band, band_meridian.get(), width); } gtk::glib::ControlFlow::Continue @@ -274,6 +281,7 @@ impl SimpleComponent for TimezonePage { overlay.set_child(Some(&map)); overlay.add_overlay(&band); overlay.add_overlay(&pin); + overlay.add_overlay(&pin_label); let frame = gtk::Frame::new(None); frame.add_css_class("timezone-map-frame"); frame.set_child(Some(&overlay)); @@ -287,6 +295,7 @@ impl SimpleComponent for TimezonePage { results_popover, map, pin, + pin_label, band, locations, filtered: Vec::new(), @@ -400,20 +409,21 @@ impl TimezonePage { self.selected_point .set((location.longitude, location.latitude)); self.band_meridian.set(zone_meridian(&location.zone)); + self.refresh_selection(sender); let width = f64::from(self.map.width()).max(1.0); let height = f64::from(self.map.height()).max(1.0); position_pin(&self.pin, self.selected_point.get(), width, height); + position_pin_label(&self.pin_label, self.selected_point.get(), width, height); position_band(&self.band, self.band_meridian.get(), width); - self.refresh_selection(sender); } fn refresh_selection(&self, sender: &ComponentSender) { let location = &self.locations[self.selected]; - self.pin.set_tooltip_text(Some(&format!( - "{}\n{}", - location.city(), - timezone_detail(&location.zone) - ))); + let city = gtk::glib::markup_escape_text(location.city()); + let detail = gtk::glib::markup_escape_text(&timezone_detail(&location.zone)); + self.pin_label.set_markup(&format!( + "{city}\n{detail}" + )); sender .output(PageOutput::SetTimezone(location.zone.clone())) .ok(); @@ -435,19 +445,14 @@ fn apply_header(root: &adw::StatusPage) { } fn load_locations() -> Vec { - // Primary source: libgweather's location database (the same one behind - // gnome-initial-setup's search). Only CITY nodes with an IANA zone belong - // in the chooser; countries, administrative regions and weather stations - // may inherit a zone too, but are not valid city search results. The tzdb - // tables stay as a fallback for systems without libgweather data. - let mut locations = gweather_locations(); - if locations.is_empty() { - locations = ZONE_TABLES - .iter() - .find_map(|path| std::fs::read_to_string(path).ok()) - .map(|table| parse_zone_table(&table)) - .unwrap_or_default(); - } + // Like GNOME Initial Setup, the chooser is backed only by the tzdb tables: + // one searchable entry per zone's reference city, no external location + // database. + let mut locations = ZONE_TABLES + .iter() + .find_map(|path| std::fs::read_to_string(path).ok()) + .map(|table| parse_zone_table(&table)) + .unwrap_or_default(); if locations.is_empty() { vec![ @@ -465,48 +470,6 @@ fn load_locations() -> Vec { } } -fn gweather_locations() -> Vec { - let mut locations = Vec::new(); - if let Some(world) = gweather::Location::world() { - collect_gweather(&world, &mut locations); - } - locations -} - -fn collect_gweather(parent: &gweather::Location, locations: &mut Vec) { - let mut child = parent.next_child(None); - while let Some(location) = child { - if location.level() == gweather::LocationLevel::City - && location.has_coords() - && location.has_timezone() - && let Some(zone) = location.timezone_str() - { - let (latitude, longitude) = location.coords(); - let name = location - .name() - .or_else(|| location.english_name()) - .map(|name| name.to_string()) - .unwrap_or_else(|| zone_city(&zone)); - let english_name = location.english_name().unwrap_or_default(); - let sort_name = location.sort_name().unwrap_or_default(); - let english_sort_name = location.english_sort_name().unwrap_or_default(); - let country = location.country_name().unwrap_or_default(); - locations.push(Location { - search_text: normalize(&format!( - "{name} {english_name} {sort_name} {english_sort_name} {country}" - )), - zone: zone.to_string(), - name, - region: country.to_string(), - latitude, - longitude, - }); - } - collect_gweather(&location, locations); - child = parent.next_child(Some(location)); - } -} - fn parse_zone_table(table: &str) -> Vec { table .lines() @@ -591,11 +554,10 @@ fn initial_location(locations: &[Location]) -> usize { .unwrap_or(0) } -/// Best location for an auto-detected zone: the city nearest to the zone's -/// representative point from the tzdb table (the zone's main city, e.g. the -/// city of Sao Paulo for America/Sao_Paulo). Picking the first database entry -/// for the zone lands the pin thousands of kilometres off — the first -/// libgweather city in America/Sao_Paulo document order is Tarauaca, in Acre. +/// Best location for an auto-detected zone: the entry nearest to the zone's +/// representative point from the tzdb table (the zone's reference city, e.g. +/// the city of Sao Paulo for America/Sao_Paulo), never an arbitrary first +/// entry that could sit far away from it. fn zone_match(locations: &[Location], zone: &str) -> Option { let reference = zone_reference_coords(zone); locations @@ -852,6 +814,41 @@ fn position_pin( } } +/// The permanent city label floats just above the pin, horizontally centered +/// on the pin's tip and clamped inside the map. +fn position_pin_label( + label: >k::Label, + (longitude, latitude): (f64, f64), + map_width: f64, + map_height: f64, +) { + let label_width = f64::from(label.width()); + let label_height = f64::from(label.height()); + if label_width < 1.0 || label_height < 1.0 { + return; + } + // Same floored projection as the pin, so the label's center lands exactly + // on the pin tip. + let x = longitude_to_x(longitude, map_width) + .floor() + .clamp(0.0, map_width); + let y = latitude_to_y(latitude, map_height) + .floor() + .clamp(0.0, map_height); + let start = (x - label_width / 2.0) + .round() + .clamp(0.0, (map_width - label_width).max(0.0)) as i32; + let top = (y - PIN_HOT_POINT_Y - 14.0 - label_height) + .round() + .clamp(0.0, (map_height - label_height).max(0.0)) as i32; + if label.margin_start() != start { + label.set_margin_start(start); + } + if label.margin_top() != top { + label.set_margin_top(top); + } +} + /// Keyboard handling shared by the search entry and the popover, mirroring /// `PlaceEntry._onKeyPressed` in GNOME Maps: Escape dismisses the list and /// Up/Down move the highlighted row (popping the list back up on first Down) @@ -975,9 +972,8 @@ mod tests { #[test] fn auto_detected_zone_prefers_the_city_nearest_the_zone_reference() { - // Both cities share America/Sao_Paulo. Tarauaca comes first in the - // libgweather database, but the zone's tzdb reference point is the - // city of Sao Paulo, so the nearest city must win. + // zone_match picks the candidate nearest to the zone's tzdb reference + // point, never an arbitrary first entry far from it. let locations = vec![ Location { zone: "America/Sao_Paulo".into(), @@ -1016,20 +1012,19 @@ mod tests { } #[test] - fn libgweather_results_are_zoned_cities_only() { - let locations = gweather_locations(); + fn loads_every_zone_from_the_tzdb_table() { + let locations = load_locations(); assert!( - !locations.is_empty(), - "libgweather must provide city data on supported systems" + locations.len() > 300, + "the tzdb tables must provide hundreds of zones, got {}", + locations.len() ); - // The database currently contains thousands more weather stations, - // countries and administrative regions than cities. A regression to - // collecting every zoned node makes this bound fail conspicuously. assert!( - locations.len() < 6_000, - "only city nodes should be collected, got {} locations", - locations.len() + locations + .iter() + .any(|location| location.zone == "America/Sao_Paulo") ); + assert!(locations.iter().any(|location| location.zone == "UTC")); } #[test] @@ -1074,9 +1069,10 @@ mod tests { eprintln!("skipping interactive test: no display available"); return; } - gtk::init().expect("gtk init"); - adw::init().expect("adw init"); + crate::pages::testutil::run_on_gtk_thread(positions_pin_interactive); + } + fn positions_pin_interactive() { let controller = TimezonePage::builder().launch(()); let carousel = adw::Carousel::new(); controller.widget().set_margin_start(72); @@ -1139,6 +1135,29 @@ mod tests { "pin must sit on the initially selected city" ); + let (label_text, label_start, label_top, label_width) = { + let model = controller.model(); + ( + model.pin_label.label().to_string(), + model.pin_label.margin_start(), + model.pin_label.margin_top(), + model.pin_label.width(), + ) + }; + assert!( + label_text.contains(location.city()) && label_text.contains("UTC"), + "the pin label must name the city and its UTC offset, got {label_text:?}" + ); + assert!( + label_top < pin_margins.1, + "the pin label must float above the pin icon" + ); + let label_center = label_start + label_width / 2; + assert!( + (f64::from(label_center) - pin_x).abs() < 2.0, + "the pin label must stay centered on the pin tip (center {label_center}, tip {pin_x})" + ); + let meridian = zone_meridian(&location.zone); let band_x1 = longitude_to_x(meridian - 7.5, width).clamp(0.0, width); let band_x2 = longitude_to_x(meridian + 7.5, width).clamp(0.0, width); @@ -1200,11 +1219,14 @@ mod tests { // Enter honors the row highlighted through the arrow-key cursor // instead of always picking the first suggestion. - search.set_text("london"); + search.set_text("san"); pump(500); let expected_zone = { let model = controller.model(); - assert!(model.filtered.len() > 1, "london must match several rows"); + assert!( + model.filtered.len() > 1, + "san must match several rows (Santiago, Santo Domingo, …)" + ); let second = model.results.row_at_index(1).unwrap(); model.results.select_row(Some(&second)); model.locations[model.filtered[1]].zone.clone() @@ -1217,9 +1239,9 @@ mod tests { }; assert_eq!(selected_zone, expected_zone); - // Cities absent from the tzdb table (the original search complaint) - // resolve through libgweather's database to the right zone. - search.set_text("curitiba"); + // A zone whose city name differs from the search term still resolves + // through the tzdb comment/country fields. + search.set_text("fortaleza"); pump(500); search.emit_activate(); pump(500); @@ -1227,7 +1249,7 @@ mod tests { let model = controller.model(); model.locations[model.selected].zone.clone() }; - assert_eq!(selected_zone, "America/Sao_Paulo"); + assert_eq!(selected_zone, "America/Fortaleza"); window.close(); pump(100); diff --git a/crates/sirius-app/src/pages/welcome.rs b/crates/sirius-app/src/pages/welcome.rs index 7ebc986..51239b3 100644 --- a/crates/sirius-app/src/pages/welcome.rs +++ b/crates/sirius-app/src/pages/welcome.rs @@ -1,14 +1,35 @@ //! Branded installer entry point shown before any configuration question. +//! +//! Mirrors GNOME Initial Setup's welcome: an edge-to-edge banner on top and a +//! greeting that cycles "Welcome" through the common languages every five +//! seconds (`GisWelcomeWidget`). use super::PageOutput; +use super::language::locale; use gettextrs::gettext; use relm4::adw::prelude::*; use relm4::{ComponentParts, ComponentSender, SimpleComponent, gtk}; use sirius_core::Branding; use std::path::Path; +use std::rc::Rc; const FALLBACK_BANNER: &str = "/usr/share/sirius/welcome-banner.png"; const DEV_BANNER: &str = "data/images/welcome-banner.png"; +const GREETING_INTERVAL_SECONDS: u32 = 5; + +/// "Welcome" in each pinned language page locale, filtered at runtime by font +/// coverage like `GisWelcomeWidget` does. +const GREETINGS: &[(&str, &str)] = &[ + ("en_US", "Welcome"), + ("pt_BR", "Boas-vindas"), + ("de_DE", "Willkommen"), + ("fr_FR", "Bienvenue"), + ("es_ES", "Bienvenidos"), + ("zh_CN", "欢迎"), + ("ja_JP", "ようこそ"), + ("ru_RU", "Добро пожаловать"), + ("ar_EG", "مرحباً"), +]; pub struct WelcomePage { branding: Branding, @@ -29,34 +50,40 @@ impl SimpleComponent for WelcomePage { view! { gtk::Box { set_orientation: gtk::Orientation::Vertical, - set_spacing: 24, - set_halign: gtk::Align::Center, - set_valign: gtk::Align::Center, - set_margin_top: 24, - set_margin_bottom: 32, + set_hexpand: true, + set_vexpand: true, #[name = "banner"] gtk::Picture { - set_width_request: 760, + set_hexpand: true, set_height_request: 300, set_content_fit: gtk::ContentFit::Cover, set_can_shrink: true, - add_css_class: "welcome-banner", - }, - - gtk::Label { - add_css_class: "title-1", - #[watch] - set_label: &welcome_title(&model.branding), + set_valign: gtk::Align::Start, }, - gtk::Button { - add_css_class: "install-pill", - add_css_class: "suggested-action", + gtk::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 24, + set_vexpand: true, set_halign: gtk::Align::Center, - #[watch] - set_label: &button_label(&model.branding), - connect_clicked => WelcomeMsg::Begin, + set_valign: gtk::Align::Center, + set_margin_bottom: 32, + + #[name = "greetings"] + adw::Carousel { + set_interactive: false, + set_halign: gtk::Align::Center, + }, + + gtk::Button { + add_css_class: "install-pill", + add_css_class: "suggested-action", + set_halign: gtk::Align::Center, + #[watch] + set_label: &button_label(&model.branding), + connect_clicked => WelcomeMsg::Begin, + }, }, } } @@ -71,6 +98,19 @@ impl SimpleComponent for WelcomePage { widgets .banner .set_filename(Some(banner_path(&model.branding))); + + // One title-1 label per displayable greeting (GisWelcomeWidget skips + // languages the system cannot render). + for (locale_id, greeting) in GREETINGS { + if !locale::initial_locale_ids().contains(locale_id) || !has_font(locale_id) { + continue; + } + let label = gtk::Label::new(Some(greeting)); + label.add_css_class("title-1"); + widgets.greetings.append(&label); + } + cycle_greetings(&widgets.greetings); + ComponentParts { model, widgets } } @@ -84,6 +124,50 @@ impl SimpleComponent for WelcomePage { } } +/// Auto-advance the greeting carousel every few seconds while it is mapped, +/// mirroring `GisWelcomeWidget`'s 5-second timeout. +fn cycle_greetings(carousel: &adw::Carousel) { + let source = Rc::new(std::cell::RefCell::new(None::)); + + { + let map_source = source.clone(); + carousel.connect_map(move |carousel| { + if map_source.borrow().is_some() { + return; + } + let carousel = carousel.clone(); + let id = gtk::glib::timeout_add_seconds_local(GREETING_INTERVAL_SECONDS, move || { + let pages = carousel.n_pages(); + if pages > 0 { + let next = ((carousel.position().ceil() as u32) + 1) % pages; + let page = carousel.nth_page(next); + carousel.scroll_to(&page, true); + } + gtk::glib::ControlFlow::Continue + }); + map_source.borrow_mut().replace(id); + }); + } + carousel.connect_unmap(move |_| { + if let Some(id) = source.borrow_mut().take() { + id.remove(); + } + }); +} + +/// Font coverage for a locale's language code, like +/// `cc_common_language_has_font`. +fn has_font(locale_id: &str) -> bool { + let language = locale_id + .split(['.', '@']) + .next() + .unwrap_or(locale_id) + .split('_') + .next() + .unwrap_or(locale_id); + locale::has_font(language) +} + fn banner_path(branding: &Branding) -> &str { let configured = branding .welcome_banner @@ -98,14 +182,6 @@ fn banner_path(branding: &Branding) -> &str { } } -fn welcome_title(branding: &Branding) -> String { - branding - .name - .as_ref() - .map(|name| gettext("Welcome to {name}").replace("{name}", name)) - .unwrap_or_else(|| gettext("Welcome")) -} - fn button_label(branding: &Branding) -> String { branding .welcome_button @@ -126,6 +202,61 @@ mod tests { ..Branding::default() }; assert_eq!(button_label(&branding), "Install Example OS"); - assert_eq!(welcome_title(&branding), "Welcome to Example OS"); + } + + #[test] + fn greetings_cover_every_pinned_locale() { + for (locale_id, _) in GREETINGS { + assert!( + locale::initial_locale_ids().contains(locale_id), + "{locale_id} has a greeting but is not a pinned locale" + ); + } + } + + // Interactive test: needs a display (skipped on headless CI). The banner + // must span the page edge-to-edge and the greeting carousel must hold + // the displayable greetings, GNOME Initial Setup style. + #[test] + fn banner_runs_edge_to_edge_and_greetings_fill_the_carousel() { + use relm4::{Component, ComponentController}; + + if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { + eprintln!("skipping interactive test: no display available"); + return; + } + crate::pages::testutil::run_on_gtk_thread(|| { + let controller = WelcomePage::builder().launch(Branding::default()); + let window = adw::Window::new(); + window.set_content(Some(controller.widget())); + window.set_default_size(960, 640); + window.present(); + + let context = gtk::glib::MainContext::default(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(800); + while std::time::Instant::now() < deadline { + while context.pending() { + context.iteration(false); + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let widgets = controller.widgets(); + assert_eq!( + widgets.banner.width(), + controller.widget().width(), + "the banner must run edge-to-edge with the page" + ); + assert!( + widgets.greetings.n_pages() >= 2, + "at least the English and Portuguese greetings must be present" + ); + let first = widgets.greetings.nth_page(0); + assert!( + first.has_css_class("title-1"), + "greetings use the title-1 style like GisWelcomeWidget" + ); + window.close(); + }); } } diff --git a/crates/sirius-app/tests/language_switch.rs b/crates/sirius-app/tests/language_switch.rs index e0421d5..fe35e0f 100644 --- a/crates/sirius-app/tests/language_switch.rs +++ b/crates/sirius-app/tests/language_switch.rs @@ -21,4 +21,13 @@ fn gettext_switches_from_portuguese_back_to_english() { set_ui_language("en_US"); assert_eq!(gettext("Language"), "Language"); + + // The ISO live session starts the installer with an empty environment, + // i.e. in the C locale, where glibc gettext ignores LANGUAGE and always + // returns msgids. Switching must work from there too (needs the + // pt_BR.UTF-8 locale installed, e.g. glibc-all-langpacks). + setlocale(LocaleCategory::LcAll, "C"); + assert_eq!(gettext("Language"), "Language"); + set_ui_language("pt_BR"); + assert_eq!(gettext("Language"), "Idioma"); } diff --git a/crates/sirius-diag/src/config.rs b/crates/sirius-diag/src/config.rs index 589e172..5c7a4f2 100644 --- a/crates/sirius-diag/src/config.rs +++ b/crates/sirius-diag/src/config.rs @@ -17,6 +17,37 @@ pub struct SiriusConfig { pub pages: PagesConfig, #[serde(default)] pub diagnostics: DiagnosticsConfig, + #[serde(default)] + pub terminal: TerminalConfig, +} + +/// Terminal launcher: which program the Ctrl+Shift+P shortcut (and the +/// optional header-bar button) opens. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct TerminalConfig { + /// Command line used to launch the terminal (program plus arguments). + #[serde(default = "default_terminal_command")] + pub command: String, + /// Whether the header-bar terminal button is shown. Defaults to hidden; + /// the Ctrl+Shift+P shortcut always works. + #[serde(default)] + pub show_button: bool, +} + +/// Built-in terminal command used when `sirius.toml` does not override it. +pub const DEFAULT_TERMINAL_COMMAND: &str = "ptyxis"; + +fn default_terminal_command() -> String { + DEFAULT_TERMINAL_COMMAND.to_string() +} + +impl Default for TerminalConfig { + fn default() -> Self { + Self { + command: default_terminal_command(), + show_button: false, + } + } } /// Which wizard pages are enabled and in what order. @@ -162,6 +193,7 @@ impl Default for SiriusConfig { warn: vec!["secure_boot".into(), "network".into(), "virt".into()], min_ram_gib: DEFAULT_MIN_RAM_GIB, }, + terminal: TerminalConfig::default(), } } } @@ -181,12 +213,25 @@ disabled = ["manual_partition"] require = ["uefi", "ram", "disk_space"] warn = ["secure_boot", "network", "virt"] min_ram_gib = 3 + +[terminal] +command = "kgx --window" +show_button = true "#; let cfg = SiriusConfig::from_toml(src).unwrap(); assert_eq!(cfg.pages.order.first().unwrap(), "welcome"); assert_eq!(cfg.pages.disabled, vec!["manual_partition".to_string()]); assert_eq!(cfg.diagnostics.require.len(), 3); assert_eq!(cfg.diagnostics.min_ram_gib, 3); + assert_eq!(cfg.terminal.command, "kgx --window"); + assert!(cfg.terminal.show_button); + } + + #[test] + fn terminal_defaults_to_hidden_button_and_ptyxis() { + let cfg = SiriusConfig::from_toml("").unwrap(); + assert_eq!(cfg.terminal.command, DEFAULT_TERMINAL_COMMAND); + assert!(!cfg.terminal.show_button); } #[test] diff --git a/crates/sirius-installer/Cargo.toml b/crates/sirius-installer/Cargo.toml index 17bf48b..0e69d34 100644 --- a/crates/sirius-installer/Cargo.toml +++ b/crates/sirius-installer/Cargo.toml @@ -44,6 +44,5 @@ polkit = "*" udisks2 = "*" NetworkManager = "*" util-linux = "*" -libgweather = "*" glycin-loaders = "*" hicolor-icon-theme = "*" diff --git a/data/sirius.toml b/data/sirius.toml index f8dd55c..5631b6f 100644 --- a/data/sirius.toml +++ b/data/sirius.toml @@ -18,3 +18,9 @@ disabled = [] require = ["uefi", "ram", "disk_space"] warn = ["secure_boot", "network", "virt"] min_ram_gib = 2 + +# Terminal launcher: opened by the Ctrl+Shift+P shortcut, and by the +# header-bar button when `show_button` is enabled (hidden by default). +# [terminal] +# command = "ptyxis" +# show_button = false diff --git a/data/style.css b/data/style.css index 5c3717b..a341e08 100644 --- a/data/style.css +++ b/data/style.css @@ -6,6 +6,15 @@ box-shadow: 0 6px 20px alpha(black, .18); } +/* Scrollable chooser lists (language, keyboard, network): drop the GTK + undershoot shadow, which paints a gray haze over the card's bottom edge + and corners whenever the list can scroll. */ +scrolledwindow.chooser-scroll undershoot.top, +scrolledwindow.chooser-scroll undershoot.bottom { + background: none; + box-shadow: none; +} + .install-pill { min-width: 190px; min-height: 48px; @@ -16,11 +25,6 @@ box-shadow: 0 8px 24px alpha(@accent_bg_color, .28); } -.welcome-banner { - border-radius: 24px; - box-shadow: 0 12px 36px alpha(black, .22); -} - .timezone-map-frame { border-radius: 18px; border: 1px solid alpha(@window_fg_color, .16); @@ -38,6 +42,15 @@ border-right: 1px solid alpha(@accent_bg_color, 0.65); } +.timezone-pin-label { + padding: 4px 10px; + border-radius: 999px; + background: alpha(@window_bg_color, .92); + color: @window_fg_color; + font-size: .8em; + box-shadow: 0 2px 8px alpha(black, .2); +} + /* Suggestions popover: keep the default opaque popover chrome, like GNOME Maps' popover.suggestions, and only restyle the row states. The list is a direct child of the popover contents, so libadwaita keeps it transparent