diff --git a/crates/openlogi-core/src/brand.rs b/crates/openlogi-core/src/brand.rs index 0c36d944..5835dd89 100644 --- a/crates/openlogi-core/src/brand.rs +++ b/crates/openlogi-core/src/brand.rs @@ -33,9 +33,10 @@ pub enum DeeplinkCommand { Show, /// Open the Settings window. OpenSettings, - /// Open the About window. + /// Open Settings on the About page. OpenAbout, - /// Run a manual update check (and show where its status is rendered). + /// Run a manual update check and open Settings on the Updates page, where + /// its status is rendered. CheckForUpdates, /// Quit the GUI. Quit, diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 43db3f22..87702587 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -61,6 +61,22 @@ impl Default for Config { } } +/// Light/dark appearance preference. `System` follows the OS appearance (the +/// historical behaviour); `Light` / `Dark` force a mode regardless of the OS. +/// Platform-free so the core crate stays GUI-agnostic — the GUI maps this onto +/// gpui-component's `ThemeMode`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Appearance { + /// Follow the operating system's light/dark setting. + #[default] + System, + /// Always use the light variant of the selected theme. + Light, + /// Always use the dark variant of the selected theme. + Dark, +} + /// App-wide preferences not tied to any particular device. /// /// All fields are `#[serde(default)]` so adding a new one is backward @@ -85,6 +101,14 @@ pub struct AppSettings { /// available — no automatic download. #[serde(default)] pub check_for_updates: bool, + /// Opt-in automatic install. When true *and* [`Self::check_for_updates`] + /// surfaces a newer version, the GUI downloads and stages it in the + /// background; the update is applied on the next restart (never mid-session, + /// and never auto-relaunched). **Off by default** — it only acts after a + /// check the user already opted into, and stays inert in unsigned dev builds + /// where verification fails closed. + #[serde(default)] + pub auto_install_updates: bool, /// True once the first-run "check for updates?" prompt has been answered /// (either way), so it is never shown again. The prompt is how a /// privacy-conscious default of `check_for_updates = false` still lets a @@ -119,6 +143,22 @@ pub struct AppSettings { /// diverted from native scrolling once this leaves the default. #[serde(default = "default_thumbwheel_sensitivity")] pub thumbwheel_sensitivity: i32, + /// Light/dark appearance preference. Defaults to following the OS. + #[serde(default)] + pub appearance: Appearance, + /// Name of the theme used in light mode (a [`crate`]-agnostic string + /// matching a gpui-component theme, e.g. `"OpenLogi Light"`). `None` uses + /// the OpenLogi brand light theme. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme_light: Option, + /// Name of the theme used in dark mode. `None` uses the OpenLogi brand dark + /// theme. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub theme_dark: Option, + /// Corner-radius override for the UI, in pixels (the Appearance page offers + /// `0` / `6` / `12`). `None` keeps each theme's own radius. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_radius: Option, } /// Out-of-the-box [`AppSettings::thumbwheel_sensitivity`]. At this value the @@ -144,11 +184,16 @@ impl Default for AppSettings { Self { launch_at_login: false, check_for_updates: false, + auto_install_updates: false, update_prompt_seen: false, show_in_menu_bar: true, auto_download_assets: true, language: None, thumbwheel_sensitivity: DEFAULT_THUMBWHEEL_SENSITIVITY, + appearance: Appearance::System, + theme_light: None, + theme_dark: None, + ui_radius: None, } } } @@ -841,6 +886,19 @@ impl Config { .identity = Some(identity); } + /// Whether `device_key` has a non-empty per-app binding overlay for the + /// foreground app `app` (bundle id). Drives the menu-bar popover's "override + /// active" badge — when the current app has its own bindings for this + /// device, the global bindings are (partly) overridden. + #[must_use] + pub fn has_app_override(&self, device_key: &str, app: &str) -> bool { + self.devices.get(device_key).is_some_and(|d| { + d.per_app_bindings + .get(app) + .is_some_and(|overlay| !overlay.is_empty()) + }) + } + /// Iterate every device we've recorded an identity for, as /// `(config_key, identity)`. Used to seed offline placeholder cards so a /// known device stays visible (with its panels) before any live probe. diff --git a/crates/openlogi-gui/Cargo.toml b/crates/openlogi-gui/Cargo.toml index 24b1646b..0c6575f6 100644 --- a/crates/openlogi-gui/Cargo.toml +++ b/crates/openlogi-gui/Cargo.toml @@ -52,7 +52,7 @@ opener = "0.8.5" # `Retained`-owned AppKit bindings so the status-item code can't leak (#99). [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6.4" -objc2-app-kit = { version = "0.3.2", features = ["NSStatusBar", "NSStatusItem", "NSStatusBarButton", "NSButton", "NSControl", "NSResponder", "NSView", "NSMenu", "NSMenuItem", "NSImage", "NSApplication"] } +objc2-app-kit = { version = "0.3.2", features = ["NSStatusBar", "NSStatusItem", "NSStatusBarButton", "NSButton", "NSControl", "NSResponder", "NSView", "NSMenu", "NSMenuItem", "NSImage", "NSApplication", "NSAppearance"] } objc2-foundation = { version = "0.3.2", features = ["NSString", "NSProcessInfo"] } # unsafe_code stays denied; the status-item/tray modules opt in locally with diff --git a/crates/openlogi-gui/action-icons/bug.svg b/crates/openlogi-gui/action-icons/bug.svg new file mode 100644 index 00000000..a9cf7b61 --- /dev/null +++ b/crates/openlogi-gui/action-icons/bug.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/crates/openlogi-gui/action-icons/scroll-text.svg b/crates/openlogi-gui/action-icons/scroll-text.svg new file mode 100644 index 00000000..c5ccf7f1 --- /dev/null +++ b/crates/openlogi-gui/action-icons/scroll-text.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/openlogi-gui/build.rs b/crates/openlogi-gui/build.rs index 1b414982..1479b669 100644 --- a/crates/openlogi-gui/build.rs +++ b/crates/openlogi-gui/build.rs @@ -1,3 +1,138 @@ +//! Build script for openlogi-gui. +//! +//! Besides the existing update-manifest env hook, this embeds the upstream +//! gpui-component themes *without* vendoring copies into this repo. Those theme +//! files live only in the gpui-component git checkout (the compiled crate +//! doesn't ship them), so we ask `cargo metadata` where gpui-component's source +//! actually resides — which is correct across the local git cache, CI, and +//! Nix's vendored `source-git` tree alike — and copy the wanted theme files +//! into `OUT_DIR`, emitting an include list that `theme.rs` pulls in. +//! +//! `OPENLOGI_THEMES_DIR` overrides the lookup with an explicit path to the +//! gpui-component `themes/` directory, as an escape hatch. +//! +//! Uses only `std` on purpose: adding a build-dependency would re-resolve the +//! lockfile and bump the precisely-pinned (Cargo.lock-only) gpui rev — so the +//! `cargo metadata` JSON is scanned for `manifest_path` values directly rather +//! than parsed with serde. + +// A build script fails by panicking, so `expect` (with a message that surfaces +// in the build log) is the idiomatic error path here — exempt it from the +// workspace's strict runtime lints. +#![allow(clippy::expect_used)] + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{env, fs}; + +/// Upstream theme file stems we embed (everything except our own openlogi.json, +/// which stays a committed, in-repo theme). +const UPSTREAM: &[&str] = &[ + "ayu", + "catppuccin", + "tokyonight", + "gruvbox", + "solarized", + "everforest", + "flexoki", + "molokai", + "spaceduck", + "matrix", + "adventure", + "alduin", + "asciinema", + "fahrenheit", + "harper", + "hybrid", + "jellybeans", + "kibble", + "macos-classic", + "mellifluous", + "twilight", +]; + fn main() { println!("cargo:rerun-if-env-changed=OPENLOGI_UPDATE_MANIFEST_URL"); + println!("cargo:rerun-if-env-changed=OPENLOGI_THEMES_DIR"); + + let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); + let src_dir = locate_themes_dir(); + let dest = out.join("themes"); + fs::create_dir_all(&dest).expect("create OUT_DIR/themes"); + + let mut generated = String::from( + "// @generated by build.rs — upstream gpui-component themes embedded at build time.\n\ + static UPSTREAM_THEME_JSON: &[&str] = &[\n", + ); + for stem in UPSTREAM { + let src = src_dir.join(format!("{stem}.json")); + let json = fs::read_to_string(&src) + .unwrap_or_else(|e| panic!("cannot read theme `{stem}` at {}: {e}", src.display())); + fs::write(dest.join(format!("{stem}.json")), json).expect("write theme into OUT_DIR"); + writeln!( + generated, + " include_str!(concat!(env!(\"OUT_DIR\"), \"/themes/{stem}.json\"))," + ) + .expect("writing to a String cannot fail"); + println!("cargo:rerun-if-changed={}", src.display()); + } + generated.push_str("];\n"); + fs::write(out.join("builtin_themes.rs"), generated).expect("write builtin_themes.rs"); +} + +/// Find the gpui-component `themes/` directory: an explicit override first, else +/// the dependency's real source location as reported by `cargo metadata`. +fn locate_themes_dir() -> PathBuf { + if let Some(dir) = env::var_os("OPENLOGI_THEMES_DIR") { + return PathBuf::from(dir); + } + + let metadata = cargo_metadata(); + // The themes live at the repo root next to (not inside) the gpui-component + // crate, so walk up from its manifest until a populated `themes/` appears. + // `gpui-component-assets` shares the same checkout root, so either match + // converges on the same directory. + for manifest in manifest_paths(&metadata).filter(|p| p.contains("gpui-component")) { + for ancestor in Path::new(manifest).ancestors() { + let themes = ancestor.join("themes"); + if themes.join("catppuccin.json").is_file() { + return themes; + } + } + } + + panic!( + "could not locate the gpui-component themes dir via `cargo metadata`; \ + set OPENLOGI_THEMES_DIR to the gpui-component `themes/` directory" + ); +} + +/// Run `cargo metadata` (locked, so it never mutates `Cargo.lock`) and return +/// its JSON stdout. +fn cargo_metadata() -> String { + println!("cargo:rerun-if-changed=../../Cargo.lock"); + let cargo = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let output = Command::new(cargo) + .args(["metadata", "--format-version=1", "--locked"]) + .current_dir(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")) + .output() + .expect("run `cargo metadata`"); + assert!( + output.status.success(), + "`cargo metadata` failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("`cargo metadata` produced non-UTF-8 output") +} + +/// Yield every `manifest_path` string value in the metadata JSON. Paths on the +/// platforms that build the GUI (macOS/Linux) contain no characters that JSON +/// would escape, so a direct scan is enough and avoids a serde build-dep. +fn manifest_paths(json: &str) -> impl Iterator { + const KEY: &str = "\"manifest_path\":\""; + json.match_indices(KEY).filter_map(|(i, _)| { + let rest = &json[i + KEY.len()..]; + rest.find('"').map(|end| &rest[..end]) + }) } diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 9628971d..2e2f041f 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Rulning" "Invert scroll direction": "Vend rulleretning om" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Vend musens rullehjul om. Din pegeplade beholder systemets rulleretning." +"About": "Om" +"Updates": "Opdateringer" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 3a5ffd3e..99480ebe 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Scrollen" "Invert scroll direction": "Scrollrichtung umkehren" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Kehrt das Scrollrad dieser Maus um. Dein Trackpad behält die Scrollrichtung des Systems." +"About": "Über" +"Updates": "Updates" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index db4634a7..3b96ae1c 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Κύλιση" "Invert scroll direction": "Αντιστροφή κατεύθυνσης κύλισης" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Αντιστρέφει τον τροχό κύλισης αυτού του ποντικιού. Το trackpad διατηρεί την κατεύθυνση κύλισης του συστήματος." +"About": "Σχετικά" +"Updates": "Ενημερώσεις" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 0e953f32..7b567aa7 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Scrolling" "Invert scroll direction": "Invert scroll direction" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction." +"About": "About" +"Updates": "Updates" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index ea579712..9374362f 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Desplazamiento" "Invert scroll direction": "Invertir dirección de desplazamiento" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Invierte la rueda de desplazamiento de este ratón. Tu trackpad conserva la dirección de desplazamiento del sistema." +"About": "Acerca de" +"Updates": "Actualizaciones" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index e6d72b0a..44fe3b8c 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Vieritys" "Invert scroll direction": "Käännä vierityssuunta" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Kääntää tämän hiiren vierityspyörän. Ohjauslevy säilyttää järjestelmän vierityssuunnan." +"About": "Tietoja" +"Updates": "Päivitykset" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index 77a5cff9..1317cd65 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Défilement" "Invert scroll direction": "Inverser le sens de défilement" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Inverse la molette de cette souris. Votre trackpad conserve le sens de défilement du système." +"About": "À propos" +"Updates": "Mises à jour" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index a788dc2d..08b290b7 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Scorrimento" "Invert scroll direction": "Inverti direzione di scorrimento" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Inverte la rotellina di questo mouse. Il trackpad mantiene la direzione di scorrimento del sistema." +"About": "Informazioni" +"Updates": "Aggiornamenti" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index e5385009..61c9d9d0 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "スクロール" "Invert scroll direction": "スクロール方向を反転" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "このマウスのスクロールホイールを反転します。トラックパッドはシステムのスクロール方向を維持します。" +"About": "概要" +"Updates": "アップデート" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index 08eb851a..c4504c0e 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "스크롤" "Invert scroll direction": "스크롤 방향 반전" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "이 마우스의 스크롤 휠을 반전합니다. 트랙패드는 시스템 스크롤 방향을 유지합니다." +"About": "정보" +"Updates": "업데이트" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index d9d6059c..2f0e55c4 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Rulling" "Invert scroll direction": "Inverter rulleretning" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Inverterer rullehjulet på denne musen. Styreplaten beholder systemets rulleretning." +"About": "Om" +"Updates": "Oppdateringer" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 33426473..51f32483 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Scrollen" "Invert scroll direction": "Scrollrichting omkeren" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Keert het scrollwiel van deze muis om. Je trackpad behoudt de scrollrichting van het systeem." +"About": "Over" +"Updates": "Updates" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index 031c8a3f..fbec6b45 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Przewijanie" "Invert scroll direction": "Odwróć kierunek przewijania" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Odwraca kółko przewijania tej myszy. Gładzik zachowuje systemowy kierunek przewijania." +"About": "O programie" +"Updates": "Aktualizacje" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index 732b9bbf..4508e5ae 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Rolagem" "Invert scroll direction": "Inverter direção de rolagem" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Inverte a roda de rolagem deste mouse. Seu trackpad mantém a direção de rolagem do sistema." +"About": "Sobre" +"Updates": "Atualizações" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index bf019918..0a13f08c 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Deslocação" "Invert scroll direction": "Inverter direção de deslocação" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Inverte a roda de deslocação deste rato. O seu trackpad mantém a direção de deslocação do sistema." +"About": "Acerca de" +"Updates": "Atualizações" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 53c947e2..32e1be75 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Прокрутка" "Invert scroll direction": "Инвертировать направление прокрутки" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Инвертирует колёсико прокрутки этой мыши. Трекпад сохраняет системное направление прокрутки." +"About": "О программе" +"Updates": "Обновления" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index be7810c3..cc6cf27f 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "Rullning" "Invert scroll direction": "Invertera rullningsriktning" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "Inverterar mushjulet på den här musen. Din styrplatta behåller systemets rullningsriktning." +"About": "Om" +"Updates": "Uppdateringar" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "Off by default — checking for updates is OpenLogi's only optional outbound network request." +"Up to date": "Up to date" +"Update available": "Update available" +"Update ready": "Update ready" +"Update failed": "Update failed" +"Automatically download and install": "Automatically download and install" +"Download updates in the background and apply them the next time OpenLogi restarts.": "Download updates in the background and apply them the next time OpenLogi restarts." +"Update source": "Update source" +"View changelog": "View changelog" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." +"Stable channel": "Stable channel" +"A native, local-first alternative to Logitech Options+.": "A native, local-first alternative to Logitech Options+." +"Changelog": "Changelog" +"Documentation": "Documentation" +"Report an issue": "Report an issue" +"Show in file manager": "Show in file manager" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." +"Appearance": "Appearance" +"Appearance mode": "Appearance mode" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "Light and dark use the matching theme; Follow system tracks the OS setting." +"Light": "Light" +"Dark": "Dark" +"Theme": "Theme" +"Corner radius": "Corner radius" +"Roundness of buttons, cards, and controls.": "Roundness of buttons, cards, and controls." +"Sharp": "Sharp" +"Round": "Round" +"All": "All" +"Filter themes…": "Filter themes…" +"No themes match “%{query}”.": "No themes match “%{query}”." +"Color theme": "Color theme" +"Interface language": "Interface language" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index e3d870bb..aa217b46 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "滚动" "Invert scroll direction": "反转滚动方向" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "反转这只鼠标的滚轮方向。触控板仍保持系统的滚动方向。" +"About": "关于" +"Updates": "更新" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "默认关闭 —— 检查更新是 OpenLogi 唯一可选的对外网络请求。" +"Up to date": "已是最新" +"Update available": "有可用更新" +"Update ready": "更新已就绪" +"Update failed": "更新失败" +"Automatically download and install": "自动下载并安装" +"Download updates in the background and apply them the next time OpenLogi restarts.": "在后台下载更新,下次重启 OpenLogi 时应用。" +"Update source": "更新来源" +"View changelog": "查看更新日志" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "无常驻更新器 —— 仅在你开启自动检查或点击「检查更新」时联网。" +"Stable channel": "稳定版通道" +"A native, local-first alternative to Logitech Options+.": "原生、本地优先的 Logitech Options+ 替代品。" +"Changelog": "更新日志" +"Documentation": "文档" +"Report an issue": "报告问题" +"Show in file manager": "在文件管理器中显示" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "未隶属于 Logitech。「Logitech」「MX Master」「Options+」为 Logitech International S.A. 的商标。" +"Appearance": "外观" +"Appearance mode": "外观模式" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "浅色与深色使用对应主题;跟随系统时跟随操作系统设置。" +"Light": "浅色" +"Dark": "深色" +"Theme": "主题" +"Corner radius": "圆角" +"Roundness of buttons, cards, and controls.": "按钮、卡片与控件的圆角程度。" +"Sharp": "直角" +"Round": "圆润" +"All": "全部" +"Filter themes…": "筛选主题…" +"No themes match “%{query}”.": "没有匹配“%{query}”的主题。" +"Color theme": "配色主题" +"Interface language": "界面语言" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index 8265bc78..e11ad21a 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "捲動" "Invert scroll direction": "反轉捲動方向" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "反轉此滑鼠的滾輪方向。觸控板會維持系統的捲動方向。" +"About": "關於" +"Updates": "更新" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "預設關閉 —— 檢查更新是 OpenLogi 唯一可選的對外網路請求。" +"Up to date": "已是最新" +"Update available": "有可用更新" +"Update ready": "更新已就緒" +"Update failed": "更新失敗" +"Automatically download and install": "自動下載並安裝" +"Download updates in the background and apply them the next time OpenLogi restarts.": "在背景下載更新,下次重新啟動 OpenLogi 時套用。" +"Update source": "更新來源" +"View changelog": "檢視更新日誌" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "無常駐更新器 —— 僅在你開啟自動檢查或點擊「檢查更新」時連線。" +"Stable channel": "穩定版通道" +"A native, local-first alternative to Logitech Options+.": "原生、本機優先的 Logitech Options+ 替代品。" +"Changelog": "更新日誌" +"Documentation": "文件" +"Report an issue": "回報問題" +"Show in file manager": "在檔案管理員中顯示" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "未隸屬於 Logitech。「Logitech」「MX Master」「Options+」為 Logitech International S.A. 的商標。" +"Appearance": "外觀" +"Appearance mode": "外觀模式" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "淺色與深色使用對應主題;跟隨系統時跟隨作業系統設定。" +"Light": "淺色" +"Dark": "深色" +"Theme": "主題" +"Corner radius": "圓角" +"Roundness of buttons, cards, and controls.": "按鈕、卡片與控制項的圓角程度。" +"Sharp": "直角" +"Round": "圓潤" +"All": "全部" +"Filter themes…": "篩選主題…" +"No themes match “%{query}”.": "沒有符合「%{query}」的主題。" +"Color theme": "配色主題" +"Interface language": "介面語言" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index f19cead1..5d51239b 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -252,3 +252,37 @@ _version: 1 "Scrolling": "捲動" "Invert scroll direction": "反轉捲動方向" "Reverse this mouse's scroll wheel. Your trackpad keeps the system scroll direction.": "反轉此滑鼠的滾輪方向。觸控板會維持系統的捲動方向。" +"About": "關於" +"Updates": "更新" +"Off by default — checking for updates is OpenLogi's only optional outbound network request.": "預設關閉 —— 檢查更新是 OpenLogi 唯一可選的對外網路請求。" +"Up to date": "已是最新" +"Update available": "有可用更新" +"Update ready": "更新已就緒" +"Update failed": "更新失敗" +"Automatically download and install": "自動下載並安裝" +"Download updates in the background and apply them the next time OpenLogi restarts.": "在背景下載更新,下次重新啟動 OpenLogi 時套用。" +"Update source": "更新來源" +"View changelog": "檢視更新日誌" +"No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates.": "無常駐更新器 —— 僅在你開啟自動檢查或點擊「檢查更新」時連線。" +"Stable channel": "穩定版通道" +"A native, local-first alternative to Logitech Options+.": "原生、本機優先的 Logitech Options+ 替代品。" +"Changelog": "更新日誌" +"Documentation": "文件" +"Report an issue": "回報問題" +"Show in file manager": "在檔案管理員中顯示" +"Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A.": "未隸屬於 Logitech。「Logitech」「MX Master」「Options+」為 Logitech International S.A. 的商標。" +"Appearance": "外觀" +"Appearance mode": "外觀模式" +"Light and dark use the matching theme; Follow system tracks the OS setting.": "淺色與深色使用對應主題;跟隨系統時跟隨作業系統設定。" +"Light": "淺色" +"Dark": "深色" +"Theme": "主題" +"Corner radius": "圓角" +"Roundness of buttons, cards, and controls.": "按鈕、卡片與控制項的圓角程度。" +"Sharp": "直角" +"Round": "圓潤" +"All": "全部" +"Filter themes…": "篩選主題…" +"No themes match “%{query}”.": "沒有符合「%{query}」的主題。" +"Color theme": "配色主題" +"Interface language": "介面語言" diff --git a/crates/openlogi-gui/src/app_assets.rs b/crates/openlogi-gui/src/app_assets.rs index d1a7353f..bfb93b7a 100644 --- a/crates/openlogi-gui/src/app_assets.rs +++ b/crates/openlogi-gui/src/app_assets.rs @@ -23,8 +23,9 @@ const LOGO_BYTES: &[u8] = include_bytes!(concat!( /// menus, embedded so they resolve identically in a packaged `.app` and a dev /// build. Served under the `action-icons/` path prefix and rendered by /// `mouse_model::picker::action_icon_path` via `svg().path(..)`. These are -/// command glyphs (paste / cut / volume / lock / …) that gpui-component's -/// bundled `IconName` set (UI chrome only) does not cover. +/// command glyphs (paste / cut / volume / lock / …) plus a couple of About-page +/// icons (changelog, bug) that gpui-component's bundled `IconName` set does not +/// cover. #[rustfmt::skip] const ACTION_ICONS: &[(&str, &[u8])] = &[ ("action-icons/arrow-left.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/arrow-left.svg"))), @@ -32,6 +33,7 @@ const ACTION_ICONS: &[(&str, &[u8])] = &[ ("action-icons/ban.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/ban.svg"))), ("action-icons/bluetooth.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/bluetooth.svg"))), ("action-icons/bolt.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/bolt.svg"))), + ("action-icons/bug.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/bug.svg"))), ("action-icons/camera.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/camera.svg"))), ("action-icons/chevron-left.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/chevron-left.svg"))), ("action-icons/chevron-right.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/chevron-right.svg"))), @@ -61,6 +63,7 @@ const ACTION_ICONS: &[(&str, &[u8])] = &[ ("action-icons/rotate-cw.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/rotate-cw.svg"))), ("action-icons/save.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/save.svg"))), ("action-icons/scissors.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/scissors.svg"))), + ("action-icons/scroll-text.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/scroll-text.svg"))), ("action-icons/search.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/search.svg"))), ("action-icons/skip-back.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/skip-back.svg"))), ("action-icons/skip-forward.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/skip-forward.svg"))), diff --git a/crates/openlogi-gui/src/app_menu.rs b/crates/openlogi-gui/src/app_menu.rs index af25f84f..3346238f 100644 --- a/crates/openlogi-gui/src/app_menu.rs +++ b/crates/openlogi-gui/src/app_menu.rs @@ -71,7 +71,9 @@ pub fn install(cx: &mut App) { } }); cx.on_action(|_: &OpenSettings, cx| crate::windows::settings::open(cx)); - cx.on_action(|_: &OpenAbout, cx| crate::windows::about::open(cx)); + cx.on_action(|_: &OpenAbout, cx| { + crate::windows::settings::open_at(crate::windows::settings::SettingsPage::About, cx); + }); cx.on_action(|_: &OpenAddDevice, cx| crate::windows::add_device::open(cx)); cx.on_action(|_: &BringAllToFront, cx| cx.activate(true)); cx.on_action(|_: &CheckForUpdates, cx| check_for_updates(cx)); @@ -113,13 +115,13 @@ pub fn rebuild(cx: &mut App) { cx.set_menus(menus(cx)); } -/// Run a manual update check and show the About window where update status is +/// Run a manual update check and open Settings → Updates where its status is /// rendered. Shared by the app menu and agent tray IPC commands. pub fn check_for_updates(cx: &mut App) { if let Some(updater) = crate::platform::updater::shared(cx) { updater.update(cx, gpui_updater::Updater::check); } - crate::windows::about::open(cx); + crate::windows::settings::open_at(crate::windows::settings::SettingsPage::Updates, cx); } fn menus(cx: &App) -> Vec { diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index 930df6d3..b1a38617 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -59,7 +59,7 @@ use gpui::{ AppContext, BorrowAppContext as _, Bounds, SharedString, Size, Styled, TitlebarOptions, WindowBounds, WindowOptions, px, }; -use gpui_component::{ActiveTheme, Root, Theme, ThemeMode}; +use gpui_component::{ActiveTheme, Root}; use openlogi_core::brand::DeeplinkCommand; use openlogi_core::config::Config; use openlogi_core::device::{DeviceInventory, DeviceModelInfo}; @@ -86,7 +86,7 @@ fn dispatch_gui_command(command: DeeplinkCommand, cx: &mut gpui::App) { } Cmd::OpenAbout => { ensure_main_window(cx); - windows::about::open(cx); + windows::settings::open_at(windows::settings::SettingsPage::About, cx); } Cmd::CheckForUpdates => { ensure_main_window(cx); @@ -192,6 +192,7 @@ fn main() -> Result<()> { app.run(move |cx| { gpui_component::init(cx); + theme::register_builtin_themes(cx); app_menu::install(cx); // Seed the Add Device window's initial state. Its buttons drive pairing @@ -584,12 +585,12 @@ fn open_main_window(inventories: &[DeviceInventory], cx: &mut gpui::App) { let options = main_window_options(cx); let opened = cx.open_window(options, |window, cx| { - Theme::change(ThemeMode::from(window.appearance()), Some(window), cx); + theme::apply_from_settings(Some(window), cx); let view = cx.new(|cx| AppView::new(inventories, window, cx)); let appearance_obs = window.observe_window_appearance(|window, cx| { - Theme::change(ThemeMode::from(window.appearance()), Some(window), cx); + theme::apply_from_settings(Some(window), cx); }); view.update(cx, |v, _| v.set_appearance_obs(appearance_obs)); diff --git a/crates/openlogi-gui/src/platform/os.rs b/crates/openlogi-gui/src/platform/os.rs index 0accec99..fdf75304 100644 --- a/crates/openlogi-gui/src/platform/os.rs +++ b/crates/openlogi-gui/src/platform/os.rs @@ -1,4 +1,7 @@ -//! Best-effort host OS version string for the diagnostics report. +//! Best-effort host OS version string for the diagnostics report, plus syncing +//! the native window chrome (titlebar) to the in-app appearance preference. + +use openlogi_core::config::Appearance; /// The OS product version (e.g. `"15.5"` on macOS), or `None` when unavailable. #[must_use] @@ -21,3 +24,36 @@ pub fn os_version() -> Option { None } } + +/// Sync the **whole app's** native chrome (system titlebar, traffic lights) to +/// the appearance preference, so a forced light/dark theme isn't betrayed by a +/// titlebar that still tracks the OS. `System` clears the override (the chrome +/// follows the OS, matching the resolved theme); `Light` / `Dark` pin the +/// matching `NSAppearance`. No-op off macOS, where window chrome isn't ours to +/// paint this way. +#[cfg(target_os = "macos")] +#[expect( + unsafe_code, + reason = "reading the framework's NSAppearanceName statics to set NSApp.appearance" +)] +pub fn set_app_appearance(appearance: Appearance) { + use objc2_app_kit::{ + NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication, + }; + use objc2_foundation::MainThreadMarker; + + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let named = match appearance { + Appearance::System => None, + // SAFETY: the `NSAppearanceName` constants are static framework globals, + // valid for the whole process; `appearanceNamed` copies what it needs. + Appearance::Light => NSAppearance::appearanceNamed(unsafe { NSAppearanceNameAqua }), + Appearance::Dark => NSAppearance::appearanceNamed(unsafe { NSAppearanceNameDarkAqua }), + }; + NSApplication::sharedApplication(mtm).setAppearance(named.as_deref()); +} + +#[cfg(not(target_os = "macos"))] +pub fn set_app_appearance(_appearance: Appearance) {} diff --git a/crates/openlogi-gui/src/platform/updater.rs b/crates/openlogi-gui/src/platform/updater.rs index fe62d11b..6a1f0375 100644 --- a/crates/openlogi-gui/src/platform/updater.rs +++ b/crates/openlogi-gui/src/platform/updater.rs @@ -3,16 +3,23 @@ //! A single shared [`Updater`] entity is installed at GPUI startup via //! [`install`] and published as a [`SharedUpdater`] global. When //! [`AppSettings::check_for_updates`] is enabled, exactly one check runs on -//! launch; the result is surfaced in the About window. No download, no polling. +//! launch; the result is surfaced in Settings → Updates. With +//! [`AppSettings::auto_install_updates`] also on, a found update downloads and +//! stages in the background (applied on next restart); otherwise no download, +//! no polling. //! -//! The manual "Check for Updates" button in About works regardless of the -//! setting — it is always user-initiated — and reuses this same shared entity, -//! so a launch-time result is already visible when the window opens. +//! The manual "Check for Updates" button in Settings → Updates works regardless +//! of the setting — it is always user-initiated — and reuses this same shared +//! entity, so a launch-time result is already visible when the window opens. -use gpui::{App, AppContext as _, Entity, Global}; -use gpui_updater::{EngineConfig, StaticManifestSource, Updater, Verification, Version}; +use gpui::{App, AppContext as _, Entity, Global, Subscription}; +use gpui_updater::{ + EngineConfig, StaticManifestSource, UpdateStatus, Updater, Verification, Version, +}; use openlogi_core::config::AppSettings; +use crate::state::AppState; + const MANIFEST_URL: &str = match option_env!("OPENLOGI_UPDATE_MANIFEST_URL") { Some(url) => url, None => "https://updates.openlogi.org/channels/stable/latest.json", @@ -28,6 +35,13 @@ pub struct SharedUpdater(pub Entity); impl Global for SharedUpdater {} +/// Holds the auto-install observer alive for the app's lifetime. Dropping the +/// [`Subscription`] would stop the "download a found update in the background" +/// behaviour, so it lives in a global rather than a local. +struct AutoInstaller(#[expect(dead_code, reason = "held to keep the observer alive")] Subscription); + +impl Global for AutoInstaller {} + /// Build a fresh updater entity for this app's static update manifest and /// running version. The asset is matched by platform metadata and, under /// [`Verification::Strict`], verified against both the manifest's SHA-256 and a @@ -79,6 +93,22 @@ fn release_format() -> &'static str { /// exactly one check on launch. Call once from the GPUI `run` closure. pub fn install(cx: &mut App, settings: &AppSettings) { let updater = new_entity(cx); + + // Watch for a check surfacing a newer version; when the user has opted into + // automatic install, download and stage it (applied on next restart, never + // mid-session). Reads the setting live so toggling it at runtime — or a + // later manual check — is honoured. Installed unconditionally; it's inert + // until both the flag is on and a check resolves to `Available`. + let auto_install = cx.observe(&updater, |updater, cx| { + let opted_in = cx + .try_global::() + .is_some_and(|s| s.app_settings().auto_install_updates); + if opted_in && matches!(updater.read(cx).status(), UpdateStatus::Available(_)) { + updater.update(cx, Updater::download_and_install); + } + }); + cx.set_global(AutoInstaller(auto_install)); + if settings.check_for_updates { updater.update(cx, Updater::check); } diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 7d256197..f35705e5 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use gpui::{App, Global}; -use openlogi_core::config::{AppSettings, Config, DeviceIdentity, Lighting}; +use openlogi_core::config::{AppSettings, Appearance, Config, DeviceIdentity, Lighting}; use openlogi_core::device::DeviceInventory; use openlogi_hid::{ DeviceRoute, DpiCapabilities, DpiInfo, SmartShiftMode, SmartShiftStatus, WriteError, @@ -1096,6 +1096,62 @@ impl AppState { } } + /// Toggle opt-in automatic install and persist it. The launch-time updater + /// observer reads this live, so a newer version found after this is enabled + /// downloads and stages on its own; no immediate side effect here. No-op + /// when unchanged. + pub fn set_auto_install_updates(&mut self, enabled: bool) { + if self.config.app_settings.auto_install_updates == enabled { + return; + } + self.config.app_settings.auto_install_updates = enabled; + if let Err(e) = self.config.save_atomic() { + warn!(error = %e, "could not persist auto-install setting"); + } + } + + /// Persist the light/dark appearance preference. The caller re-applies the + /// live theme via [`crate::theme::apply_from_settings`]; this only writes the + /// choice. No-op when unchanged. + pub fn set_appearance(&mut self, appearance: Appearance) { + if self.config.app_settings.appearance == appearance { + return; + } + self.config.app_settings.appearance = appearance; + if let Err(e) = self.config.save_atomic() { + warn!(error = %e, "could not persist appearance setting"); + } + } + + /// Persist the chosen theme name for one mode (`None` = the OpenLogi brand + /// theme). No-op when unchanged. + pub fn set_theme(&mut self, dark: bool, name: Option) { + let slot = if dark { + &mut self.config.app_settings.theme_dark + } else { + &mut self.config.app_settings.theme_light + }; + if *slot == name { + return; + } + *slot = name; + if let Err(e) = self.config.save_atomic() { + warn!(error = %e, "could not persist theme setting"); + } + } + + /// Persist the UI corner-radius override (`None` = each theme's own radius). + /// No-op when unchanged. + pub fn set_ui_radius(&mut self, radius: Option) { + if self.config.app_settings.ui_radius == radius { + return; + } + self.config.app_settings.ui_radius = radius; + if let Err(e) = self.config.save_atomic() { + warn!(error = %e, "could not persist UI radius setting"); + } + } + /// Set the thumb-wheel sensitivity (clamped to the valid range), publish it /// to the gesture watcher via the shared atomic, and persist it. No-op when /// unchanged. Disk failures are logged, not propagated. diff --git a/crates/openlogi-gui/src/theme.rs b/crates/openlogi-gui/src/theme.rs index 1e7e2165..9b78d355 100644 --- a/crates/openlogi-gui/src/theme.rs +++ b/crates/openlogi-gui/src/theme.rs @@ -12,8 +12,11 @@ //! own widgets — which is what keeps a popover from rendering white under //! an otherwise dark UI (see `main.rs`'s appearance wiring). -use gpui::{App, Hsla, Styled, hsla, rgb}; -use gpui_component::ActiveTheme as _; +use gpui::{App, Hsla, Styled, Window, hsla, px, rgb}; +use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry}; +use openlogi_core::config::Appearance; + +use crate::state::AppState; /// Primary action / selection blue. Brand colour, identical in both modes — /// it reads on the light card surfaces and the dark window alike. @@ -39,11 +42,11 @@ pub const GALLERY_PHOTO_H: f32 = 230.; /// gpui-component) surfaces. Resolved once per render via [`palette`] and /// passed down to the free helper builders. /// -/// We hand-pick both variants rather than reading gpui-component's tokens: -/// its shadcn-neutral palette collapses the raised-surface and hover fills -/// onto the same neutral step (`accent` falls back to `secondary`), which -/// would flatten the app's layered card/hover look. Keeping our own values -/// preserves the tuned dark appearance and gives a controlled light one. +/// These are now *derived from the active gpui-component theme's semantic +/// tokens* (see [`palette`]), so the hand-painted surfaces re-skin with whatever +/// theme the user selects in Settings → Appearance — the same `cx.theme()` the +/// framework widgets read. The bundled "OpenLogi" theme (`themes/openlogi.json`) +/// encodes the original tuned values, so the default look is unchanged. #[derive(Clone, Copy, Debug)] pub struct Palette { /// Window background. @@ -60,44 +63,121 @@ pub struct Palette { pub text_muted: Hsla, } -impl Palette { - /// The dark palette — the original OpenLogi look, unchanged. - #[must_use] - pub fn dark() -> Self { - Self { - bg: rgb(0x001a_1a1d).into(), - surface: rgb(0x0022_2227).into(), - surface_hover: rgb(0x002c_2c33).into(), - border: rgb(0x002f_2f36).into(), - text_primary: rgb(0x00e8_e8ec).into(), - text_muted: rgb(0x008a_8a93).into(), - } +/// Derive the app palette from the active gpui-component theme's semantic +/// tokens, so the hand-painted surfaces (window, cards, mouse model) re-skin +/// with the selected theme exactly as the framework widgets do. +/// +/// - `bg` ← `background` (window) +/// - `surface` / `surface_hover` ← `secondary` / `secondary_hover` (cards). The +/// bundled OpenLogi theme sets `group_box` to the same colour, so the Fill +/// group-box cards and the bespoke `pal.surface` cards match. +/// - `border`, `text_primary` ← `foreground`, `text_muted` ← `muted_foreground`. +#[must_use] +pub fn palette(cx: &App) -> Palette { + let t = cx.theme(); + Palette { + bg: t.background, + surface: t.secondary, + surface_hover: t.secondary_hover, + border: t.border, + text_primary: t.foreground, + text_muted: t.muted_foreground, } +} - /// The light palette — white cards on a soft-grey window, tuned to sit - /// alongside gpui-component's light popover/surface tokens. - #[must_use] - pub fn light() -> Self { - Self { - bg: rgb(0x00f4_f4f6).into(), - surface: rgb(0x00ff_ffff).into(), - surface_hover: rgb(0x00e9_e9ee).into(), - border: rgb(0x00d9_d9e0).into(), - text_primary: rgb(0x001a_1a1d).into(), - text_muted: rgb(0x006b_6b73).into(), +/// Our brand theme (light + dark), encoding the original tuned surfaces. Kept as +/// a readable, committed JSON. The upstream gpui-component themes are *not* +/// vendored into this repo — `build.rs` copies them from the pinned dependency +/// checkout into `OUT_DIR` and generates the `UPSTREAM_THEME_JSON` list included +/// just below (gpui-component doesn't ship them inside its compiled crate, so +/// they must be embedded to be selectable). +const OPENLOGI_THEME_JSON: &str = include_str!("../themes/openlogi.json"); + +// Defines `static UPSTREAM_THEME_JSON: &[&str]` from build-time-embedded copies. +include!(concat!(env!("OUT_DIR"), "/builtin_themes.rs")); + +/// The default brand theme names — slots [`apply_from_settings`] falls back to. +pub const OPENLOGI_LIGHT: &str = "OpenLogi Light"; +pub const OPENLOGI_DARK: &str = "OpenLogi Dark"; + +/// Register every bundled theme into the [`ThemeRegistry`]. Call once at +/// startup, after `gpui_component::init` (which seeds the registry global). Our +/// brand theme loads first; the upstream themes follow. +pub fn register_builtin_themes(cx: &mut App) { + let registry = ThemeRegistry::global_mut(cx); + for json in std::iter::once(OPENLOGI_THEME_JSON).chain(UPSTREAM_THEME_JSON.iter().copied()) { + if let Err(error) = registry.load_themes_from_str(json) { + tracing::warn!(%error, "failed to load a bundled theme"); } } } -/// Resolve the app palette from the active gpui-component theme mode, so the -/// hand-painted surfaces follow the same light/dark switch as the widgets. -#[must_use] -pub fn palette(cx: &App) -> Palette { - if cx.theme().mode.is_dark() { - Palette::dark() - } else { - Palette::light() +/// Resolve the user's stored appearance preference and apply it to the global +/// [`Theme`]. Reads [`AppState`] live, so it is the single entry point for first +/// paint, OS-appearance changes, and live edits on the Appearance page: +/// +/// - the chosen named themes fill the light / dark slots (falling back to the +/// OpenLogi brand theme); +/// - `System` follows the OS appearance, `Light` / `Dark` force it; +/// - a chosen corner radius is applied last (after `Theme::change`, which would +/// otherwise reset it to the theme's own radius). +/// +/// Pass the window being built (first paint / appearance observer) so its OS +/// appearance is read directly and it repaints; pass `None` from a settings +/// edit (no window in hand) — every open window is refreshed instead. +pub fn apply_from_settings(window: Option<&mut Window>, cx: &mut App) { + let (appearance, light_name, dark_name, radius) = + cx.try_global::() + .map_or((Appearance::default(), None, None, None), |state| { + let s = state.app_settings(); + ( + s.appearance, + s.theme_light.clone(), + s.theme_dark.clone(), + s.ui_radius, + ) + }); + + // Sync the native window chrome (titlebar) to the pref first, so the + // `System` branch below reads the *real* OS appearance rather than a stale + // forced override. + crate::platform::os::set_app_appearance(appearance); + let os_appearance = cx.window_appearance(); + + // Pull the chosen configs out of the registry before borrowing the Theme + // mutably (both live as globals). + let (light, dark) = { + let registry = ThemeRegistry::global(cx); + let pick = |name: Option<&str>, fallback: &str| { + name.and_then(|n| registry.themes().get(n).cloned()) + .or_else(|| registry.themes().get(fallback).cloned()) + }; + ( + pick(light_name.as_deref(), OPENLOGI_LIGHT), + pick(dark_name.as_deref(), OPENLOGI_DARK), + ) + }; + { + let theme = Theme::global_mut(cx); + if let Some(light) = light { + theme.light_theme = light; + } + if let Some(dark) = dark { + theme.dark_theme = dark; + } + } + + let mode = match appearance { + Appearance::System => ThemeMode::from(os_appearance), + Appearance::Light => ThemeMode::Light, + Appearance::Dark => ThemeMode::Dark, + }; + Theme::change(mode, window, cx); + + if let Some(radius) = radius { + Theme::global_mut(cx).radius = px(f32::from(radius)); } + cx.refresh_windows(); } /// [`ACCENT_BLUE`] as an [`Hsla`] — the selection accent for borders and fills diff --git a/crates/openlogi-gui/src/windows/about.rs b/crates/openlogi-gui/src/windows/about.rs deleted file mode 100644 index 967cef09..00000000 --- a/crates/openlogi-gui/src/windows/about.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! The About window — a small standalone OS window (menu / footer link) -//! showing the app logo, wordmark, version, a one-line description, outbound -//! links, and a manual "Check for Updates" control backed by [`gpui_updater`]. -//! -//! The logo is the embedded `openlogi.png` served by [`crate::app_assets`], so -//! `img()` resolves it the same inside a packaged `.app` as in a dev build. - -use gpui::{ - App, ClipboardItem, Context, Entity, FocusHandle, FontWeight, InteractiveElement, IntoElement, - ParentElement as _, Render, Size, StatefulInteractiveElement as _, Styled as _, Subscription, - Window, div, img, px, -}; -use gpui_component::{IconName, button::Button, h_flex, v_flex}; -use gpui_updater::{UpdateStatus, Updater}; - -use openlogi_core::brand::{RELEASES_URL, REPO_URL, release_tag_url}; - -use crate::app_menu::{CloseWindow, Minimize, Zoom}; -use crate::theme; -use crate::windows::{self, AuxWindow}; - -/// Standalone About window root view. -pub struct AboutView { - focus_handle: FocusHandle, - #[allow(dead_code, reason = "held to keep the appearance observer alive")] - appearance_obs: Option, - updater: Entity, - #[allow(dead_code, reason = "held to keep the updater observation alive")] - updater_obs: Subscription, - /// `true` for ~2s after a diagnostics copy, so the button can flip its label to a confirmation. - copied: bool, - /// Bumped on each copy so a stale reset timer can't clear a newer confirmation. - copied_gen: u64, -} - -impl AboutView { - fn new(window: &mut Window, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - focus_handle.focus(window, cx); - // Reuse the app-wide shared updater installed at launch, so a launch-time - // check result is already visible here. Fall back to a fresh one if it - // somehow wasn't installed. - let updater = match crate::platform::updater::shared(cx) { - Some(updater) => updater, - None => crate::platform::updater::new_entity(cx), - }; - let updater_obs = cx.observe(&updater, |_, _, cx| cx.notify()); - Self { - focus_handle, - appearance_obs: None, - updater, - updater_obs, - copied: false, - copied_gen: 0, - } - } - - /// A "Copy Diagnostics" button that puts a privacy-filtered report on the clipboard, then confirms for ~2s. - fn diagnostics_button(&self, cx: &mut Context) -> impl IntoElement { - let label = if self.copied { - tr!("Copied!") - } else { - tr!("Copy Diagnostics") - }; - Button::new("about-copy-diagnostics") - .outline() - .icon(IconName::Copy) - .label(label) - .on_click(cx.listener(|this, _, _, cx| { - let report = crate::diagnostics::collect(cx).to_markdown(); - cx.write_to_clipboard(ClipboardItem::new_string(report)); - this.copied = true; - this.copied_gen = this.copied_gen.wrapping_add(1); - let generation = this.copied_gen; - cx.notify(); - cx.spawn(async move |view, cx| { - cx.background_executor() - .timer(std::time::Duration::from_secs(2)) - .await; - view.update(cx, |view, cx| { - if view.copied_gen == generation { - view.copied = false; - cx.notify(); - } - }) - .ok(); - }) - .detach(); - })) - } - - /// The "Check for Updates" control plus a one-line status message and a - /// contextual action (install when available, restart when staged). - fn update_section(&self, cx: &mut Context) -> impl IntoElement { - let pal = theme::palette(cx); - let status = self.updater.read(cx).status().clone(); - let updater = self.updater.clone(); - - let action = match &status { - UpdateStatus::Available(_) => { - let u = updater.clone(); - Some( - Button::new("update-install") - .outline() - .label(tr!("Download & Install")) - .on_click(move |_, _, cx| { - u.update(cx, Updater::download_and_install); - }), - ) - } - UpdateStatus::Staged(_) => { - let u = updater.clone(); - Some( - Button::new("update-restart") - .outline() - .label(tr!("Restart to Update")) - .on_click(move |_, _, cx| { - u.update(cx, |u, cx| u.restart(cx)); - }), - ) - } - _ => None, - }; - - let message = match &status { - UpdateStatus::Idle => None, - UpdateStatus::Checking => Some(tr!("Checking for updates…")), - UpdateStatus::UpToDate => Some(tr!("You're on the latest version.")), - UpdateStatus::Available(v) => { - Some(tr!("Version %{version} is available.", version => v)) - } - UpdateStatus::Downloading { downloaded, total } => Some(match total { - Some(t) if *t > 0 => tr!( - "Downloading… %{percent}%", - percent => (*downloaded * 100 / *t).to_string() - ), - _ => tr!( - "Downloading… %{size} MB", - size => (*downloaded / 1_048_576).to_string() - ), - }), - UpdateStatus::Installing => Some(tr!("Installing…")), - UpdateStatus::Staged(v) => Some(tr!("Version %{version} is ready.", version => v)), - UpdateStatus::Errored(e) => Some(tr!("Update failed: %{error}", error => e.clone())), - }; - - let check = { - let u = updater.clone(); - Button::new("update-check") - .outline() - .label(tr!("Check for Updates")) - .on_click(move |_, _, cx| { - u.update(cx, Updater::check); - }) - }; - - v_flex() - .gap_2() - .items_center() - .child(h_flex().gap_3().child(check).children(action)) - .children(message.map(|text| { - div() - .max_w(px(280.)) - .text_xs() - .text_center() - .text_color(pal.text_muted) - .child(text) - })) - } -} - -impl AuxWindow for AboutView { - fn set_appearance_obs(&mut self, sub: Subscription) { - self.appearance_obs = Some(sub); - } -} - -/// Open the About window, or focus it if it's already open. -pub fn open(cx: &mut App) { - windows::open_or_focus( - |reg| &mut reg.about, - tr!("About OpenLogi"), - Size::new(px(360.), px(460.)), - AboutView::new, - cx, - ); -} - -impl Render for AboutView { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let pal = theme::palette(cx); - - v_flex() - .size_full() - .bg(pal.bg) - .text_color(pal.text_primary) - .track_focus(&self.focus_handle) - .on_action(|_: &CloseWindow, window, _| window.remove_window()) - .on_action(|_: &Minimize, window, _| window.minimize_window()) - .on_action(|_: &Zoom, window, _| window.zoom_window()) - .items_center() - .justify_center() - .gap_3() - .p_8() - .child(img(crate::app_assets::LOGO).w(px(72.)).h(px(72.))) - .child( - div() - .text_2xl() - .font_weight(FontWeight::BOLD) - .child("OpenLogi"), - ) - .child( - div() - .id("about-version") - .text_sm() - .text_color(pal.text_muted) - .cursor_pointer() - .hover(|s| s.text_color(pal.text_primary)) - .child(concat!("v", env!("CARGO_PKG_VERSION"))) - .on_click(|_, _, cx| cx.open_url(&release_tag_url(env!("CARGO_PKG_VERSION")))), - ) - .child( - div() - .max_w(px(280.)) - .text_sm() - .text_center() - .text_color(pal.text_muted) - .child(tr!( - "Open-source Logitech mouse configuration — DPI, SmartShift, button \ - bindings, and gestures." - )), - ) - .child( - h_flex() - .gap_3() - .pt_2() - .child( - Button::new("about-repo") - .outline() - .icon(IconName::Github) - .label(tr!("GitHub")) - .on_click(|_, _, cx| cx.open_url(REPO_URL)), - ) - .child( - Button::new("about-releases") - .outline() - .icon(IconName::ExternalLink) - .label(tr!("Releases")) - .on_click(|_, _, cx| cx.open_url(RELEASES_URL)), - ), - ) - .child(self.update_section(cx)) - .child(self.diagnostics_button(cx)) - .child( - div() - .text_xs() - .text_color(pal.text_muted) - .child(tr!("Licensed under MIT OR Apache-2.0")), - ) - } -} diff --git a/crates/openlogi-gui/src/windows/mod.rs b/crates/openlogi-gui/src/windows/mod.rs index ea0687ad..9d6053cb 100644 --- a/crates/openlogi-gui/src/windows/mod.rs +++ b/crates/openlogi-gui/src/windows/mod.rs @@ -1,14 +1,14 @@ -//! Auxiliary application windows (Settings, About) and a registry that keeps -//! each one a singleton. +//! Auxiliary application windows (Settings, Add Device, …) and a registry that +//! keeps each one a singleton. About and Updates are pages inside Settings, not +//! their own windows. //! -//! macOS apps open exactly one Settings / About window: re-triggering the -//! menu item, ⌘, or the footer link focuses the existing window rather than -//! stacking a second copy. [`WindowRegistry`] holds the live [`WindowHandle`] -//! per slot; [`open_or_focus`] activates it when still open, otherwise opens a -//! fresh one wired for per-window light/dark tracking (mirroring the main -//! window's appearance observer in `main.rs`). +//! macOS apps open exactly one Settings window: re-triggering the menu item, ⌘, +//! or a footer link focuses the existing window rather than stacking a second +//! copy. [`WindowRegistry`] holds the live [`WindowHandle`] per slot; +//! [`open_or_focus`] activates it when still open, otherwise opens a fresh one +//! wired for per-window light/dark tracking via +//! [`crate::theme::apply_from_settings`]. -pub mod about; pub mod add_device; pub mod settings; pub mod update_consent; @@ -17,7 +17,7 @@ use gpui::{ App, AppContext as _, Bounds, Context, Global, Pixels, Render, SharedString, Size, Styled as _, Subscription, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, }; -use gpui_component::{ActiveTheme as _, Root, Theme, ThemeMode}; +use gpui_component::{ActiveTheme as _, Root}; use tracing::warn; /// One live handle per auxiliary window, stored as a GPUI global so the menu @@ -29,7 +29,6 @@ pub struct WindowRegistry { /// background (mouse hook + watchers). pub main: Option>, pub settings: Option>, - pub about: Option>, pub add_device: Option>, pub update_consent: Option>, } @@ -83,10 +82,10 @@ pub fn open_or_focus( }; let opened = cx.open_window(options, |window, cx| { - Theme::change(ThemeMode::from(window.appearance()), Some(window), cx); + crate::theme::apply_from_settings(Some(window), cx); let view = cx.new(|cx| build_view(window, cx)); let appearance_obs = window.observe_window_appearance(|window, cx| { - Theme::change(ThemeMode::from(window.appearance()), Some(window), cx); + crate::theme::apply_from_settings(Some(window), cx); }); view.update(cx, |v, _| v.set_appearance_obs(appearance_obs)); cx.new(|cx| Root::new(view, window, cx).bg(cx.theme().background)) diff --git a/crates/openlogi-gui/src/windows/settings.rs b/crates/openlogi-gui/src/windows/settings.rs index 63bf001c..37b42964 100644 --- a/crates/openlogi-gui/src/windows/settings.rs +++ b/crates/openlogi-gui/src/windows/settings.rs @@ -5,44 +5,116 @@ //! Uses gpui-component's Settings widget so page navigation, search, and the //! left sidebar share the same behaviour as the rest of that component set. -// `.on_click` on the `.id(...)`-stateful asset action buttons (and the macOS -// permission rows) needs this on every platform, so it isn't macOS-gated. -use gpui::StatefulInteractiveElement as _; -#[cfg(any(target_os = "macos", target_os = "linux"))] -use gpui::rgb; -use gpui::{ - AnyElement, App, AppContext as _, BorrowAppContext as _, Context, Entity, FocusHandle, - InteractiveElement, IntoElement, ParentElement as _, Render, SharedString, Size, Styled as _, - Subscription, Window, div, prelude::FluentBuilder as _, px, +// Shared imports for the whole Settings module, re-exported so each page +// submodule can pull them in with `use super::{…}`. Traits are imported by name +// (not `as _`) so the re-export carries their methods to the submodules; the +// `.on_click` / track-focus methods need them on every platform. +pub(super) use std::rc::Rc; + +pub(super) use gpui::{ + AnyElement, App, AppContext, Axis, BorrowAppContext, ClipboardItem, Context, Entity, + FocusHandle, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, Render, + SharedString, Size, StatefulInteractiveElement, Styled, Subscription, Window, div, img, + prelude::FluentBuilder, px, rgb, }; -use gpui_component::{ - IconName, IndexPath, Sizable, h_flex, +pub(super) use gpui_component::{ + ActiveTheme, Disableable, Icon, IconName, IndexPath, Selectable, Sizable, Theme, ThemeColor, + ThemeMode, ThemeRegistry, + button::{Button, ButtonGroup, ButtonVariants}, + group_box::GroupBoxVariant, + h_flex, + input::{Input, InputEvent, InputState}, select::{Select, SelectEvent, SelectItem, SelectState}, - setting::{SettingField, SettingGroup, SettingItem, SettingPage, Settings}, + setting::{SelectIndex, SettingField, SettingGroup, SettingItem, SettingPage, Settings}, slider::{Slider, SliderEvent, SliderState}, + tag::Tag, + theme::ThemeConfig, v_flex, }; -use openlogi_core::config::{ - DEFAULT_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, +pub(super) use gpui_updater::{UpdateStatus, Updater}; +pub(super) use openlogi_core::brand::{HELP_URL, RELEASES_URL, REPO_URL}; +pub(super) use openlogi_core::config::{ + Appearance, DEFAULT_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY, + MIN_THUMBWHEEL_SENSITIVITY, }; -use crate::app_menu::{CloseWindow, Minimize, Zoom}; +pub(super) use crate::app_menu::{CloseWindow, Minimize, Zoom}; #[cfg(target_os = "macos")] -use crate::platform::permissions::Permission; +pub(super) use crate::platform::permissions::Permission; #[cfg(any(target_os = "macos", target_os = "linux"))] -use crate::platform::permissions::{self, PermissionStatus}; -use crate::state::AppState; -use crate::theme::{self, Palette}; +pub(super) use crate::platform::permissions::PermissionStatus; +pub(super) use crate::state::AppState; +pub(super) use crate::theme::{self, Palette}; +pub(super) use crate::{AssetCommand, AssetControl}; + use crate::windows::{self, AuxWindow}; -use crate::{AssetCommand, AssetControl}; + +mod about; +mod appearance; +mod assets; +mod general; +mod language; +mod permissions; +mod updates; + +/// Which sidebar page the window opens to. Maps to the page order in +/// [`SettingsView::render`]; menu items deep-link here (About / Updates). +#[derive(Clone, Copy, Default)] +pub enum SettingsPage { + #[default] + General, + Updates, + About, +} + +impl SettingsPage { + /// Sidebar index — must track the `.page(...)` order in `render`. + fn index(self) -> usize { + match self { + Self::General => 0, + Self::Updates => 1, + Self::About => 5, + } + } +} + +/// Appearance-page theme-grid filter. View-local (not persisted) UI state. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub(super) enum ThemeFilter { + #[default] + All, + Light, + Dark, +} /// Standalone Settings window root view. pub struct SettingsView { focus_handle: FocusHandle, #[allow(dead_code, reason = "held to keep the appearance observer alive")] appearance_obs: Option, - language_select: Entity>>, + /// Which themes the Appearance grid shows (All / Light / Dark). + theme_filter: ThemeFilter, + /// Free-text filter for the Appearance theme grid (search 50+ themes by name). + theme_search: Entity, + /// Page selected when the window first opens. Consumed once by the Settings + /// widget's keyed state, so it only steers a fresh open (an already-open + /// window is just focused). + initial_page: SettingsPage, + language_select: Entity>>, sensitivity_slider: Entity, + /// Shared app-wide updater, surfaced on the Updates page. A launch-time + /// check result is already visible when the window opens. + updater: Entity, + #[allow( + dead_code, + reason = "held to re-render the Updates page on status change" + )] + updater_obs: Subscription, + /// `true` for ~2s after a diagnostics copy, so the About button can flip its + /// label to a confirmation. + copied: bool, + /// Bumped on each copy so a stale reset timer can't clear a newer confirmation. + copied_gen: u64, /// Asset-cache size blurb, computed once when the window opens rather than /// re-walking the cache on every render. A snapshot — reopen to refresh /// after a Clear. @@ -54,14 +126,29 @@ impl SettingsView { clippy::cast_precision_loss, reason = "sensitivity bounds are tiny 1..=100 integers — exact in f32" )] - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new(initial_page: SettingsPage, window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); focus_handle.focus(window, cx); + // Reuse the app-wide shared updater installed at launch, so a launch-time + // check result is already visible. Fall back to a fresh one if it somehow + // wasn't installed. + let updater = crate::platform::updater::shared(cx) + .unwrap_or_else(|| crate::platform::updater::new_entity(cx)); + let updater_obs = cx.observe(&updater, |_, _, cx| cx.notify()); + + let theme_search = + cx.new(|cx| InputState::new(window, cx).placeholder(tr!("Filter themes…"))); + cx.subscribe(&theme_search, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + .detach(); let current = cx .try_global::() .and_then(|s| s.app_settings().language.clone()); - let options = language_options(); - let selected = selected_language_index(current.as_deref(), &options); + let options = language::language_options(); + let selected = language::selected_language_index(current.as_deref(), &options); let language_select = cx.new(|cx| SelectState::new(options, Some(selected), window, cx)); cx.subscribe_in(&language_select, window, Self::on_language_select) .detach(); @@ -83,9 +170,16 @@ impl SettingsView { Self { focus_handle, appearance_obs: None, + theme_filter: ThemeFilter::All, + theme_search, + initial_page, language_select, sensitivity_slider, - asset_cache_desc: cache_size_description(), + updater, + updater_obs, + copied: false, + copied_gen: 0, + asset_cache_desc: assets::cache_size_description(), } } @@ -117,8 +211,8 @@ impl SettingsView { fn on_language_select( &mut self, - _: &Entity>>, - event: &SelectEvent>, + _: &Entity>>, + event: &SelectEvent>, _: &mut Window, cx: &mut Context, ) { @@ -141,13 +235,21 @@ impl AuxWindow for SettingsView { } } -/// Open the Settings window, or focus it if it's already open. +/// Open the Settings window on its default (General) page, or focus it if it's +/// already open. pub fn open(cx: &mut App) { + open_at(SettingsPage::General, cx); +} + +/// Open the Settings window on a specific page, or focus it if it's already +/// open. The page only steers a *fresh* open — an already-open window is just +/// focused on whatever page it last showed (the Settings widget owns selection). +pub fn open_at(page: SettingsPage, cx: &mut App) { windows::open_or_focus( |reg| &mut reg.settings, "Settings", - Size::new(px(820.), px(520.)), - SettingsView::new, + Size::new(px(840.), px(600.)), + move |window, cx| SettingsView::new(page, window, cx), cx, ); } @@ -155,6 +257,7 @@ pub fn open(cx: &mut App) { impl Render for SettingsView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let pal = theme::palette(cx); + let view = cx.entity(); div() .size_full() @@ -165,486 +268,28 @@ impl Render for SettingsView { .on_action(|_: &Minimize, window, _| window.minimize_window()) .on_action(|_: &Zoom, window, _| window.zoom_window()) .child( + // Outline group boxes give every page bordered cards (depth / + // definition that the flat Fill variant lacked); the hero / + // source / config blocks are custom rows inside them. Settings::new("settings") + .with_group_variant(GroupBoxVariant::Outline) .sidebar_width(px(210.)) - .page(general_page(self.sensitivity_slider.clone())) - .page(permissions_page(pal)) - .page(assets_page(pal, self.asset_cache_desc.clone())) - .page(language_page(self.language_select.clone())), - ) - } -} - -fn general_page(sensitivity_slider: Entity) -> SettingPage { - let group = SettingGroup::new() - .item( - SettingItem::new( - tr!("Thumb Wheel Sensitivity"), - SettingField::render(move |_, _, cx| { - sensitivity_field(&sensitivity_slider, cx) - }), - ) - .description(tr!( - "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger." - )), - ) - .item( - SettingItem::new( - tr!("Launch at login"), - SettingField::switch( - |cx| { - cx.try_global::() - .is_some_and(|s| s.app_settings().launch_at_login) - }, - |enabled, cx| { - cx.update_global::(move |s, _| { - s.set_launch_at_login(enabled); - }); - cx.refresh_windows(); - }, - ), - ) - .description(tr!( - "Automatically start OpenLogi when you log in." - )), - ) - .item( - SettingItem::new( - tr!("Check for updates"), - SettingField::switch( - |cx| { - cx.try_global::() - .is_some_and(|s| s.app_settings().check_for_updates) - }, - |enabled, cx| { - cx.update_global::(move |s, _| { - s.set_check_for_updates(enabled); - }); - cx.refresh_windows(); - }, - ), - ) - .description(tr!( - "Check once per launch for a new version (query only — no automatic download)." - )), - ); - - #[cfg(target_os = "macos")] - let group = group.item( - SettingItem::new( - tr!("Show in menu bar"), - SettingField::switch( - |cx| { - cx.try_global::() - .is_some_and(|s| s.app_settings().show_in_menu_bar) - }, - |enabled, cx| { - cx.update_global::(move |s, _| { - s.set_show_in_menu_bar(enabled); - }); - cx.refresh_windows(); - }, - ), - ) - .description(tr!( - "Keep OpenLogi's icon in the menu bar. When off, it stays in the Dock instead." - )), - ); - - SettingPage::new(tr!("General")) - .icon(IconName::Settings) - .resettable(false) - .group(group) -} - -#[cfg_attr( - not(any(target_os = "macos", target_os = "linux")), - allow(unused_variables) -)] -fn permissions_page(pal: Palette) -> SettingPage { - let page = SettingPage::new(tr!("Permissions")) - .icon(IconName::Info) - .resettable(false); - - #[cfg(target_os = "macos")] - let page = page.group( - SettingGroup::new() - .item(permission_item( - "perm-accessibility", - tr!("Accessibility"), - tr!("Needed for gesture and button remapping (event tap)."), - Permission::Accessibility, - |cx| { - // The agent owns the hook, so this is *its* grant, - // reported over IPC; while not connected the state is - // genuinely unknown, not denied. - match cx.try_global::().and_then(AppState::agent_status) { - Some(status) if status.accessibility_granted => PermissionStatus::Granted, - Some(_) => PermissionStatus::Denied, - None => PermissionStatus::Unknown, - } - }, - pal, - )) - .item(permission_item( - "perm-input-monitoring", - tr!("Input Monitoring"), - tr!("Needed to read HID++ data, including Bluetooth-direct mice."), - Permission::InputMonitoring, - |_| permissions::input_monitoring(), - pal, - )) - .item(permission_item( - "perm-bluetooth", - tr!("Bluetooth"), - tr!("Allows OpenLogi to use CoreBluetooth (not required for HID access)."), - Permission::Bluetooth, - |_| permissions::bluetooth(), - pal, - )), - ); - - #[cfg(target_os = "linux")] - let page = page.group(SettingGroup::new().item({ - // Description is only shown when access is not yet granted — no noise - // when everything is already working. - SettingItem::new( - tr!("Input device access"), - SettingField::render(move |_, _, _| { - let status = permissions::input_device_access(); - let field = gpui_component::v_flex().gap_1().child(status_badge(status)); - let hint = match status { - PermissionStatus::Denied => Some(tr!( - "OpenLogi needs write access to /dev/uinput (for button \ - remapping) and read/write access to /dev/hidraw* (for HID++ \ - communication). Install the OpenLogi udev rules to grant \ - access — see the Linux install guide." - )), - PermissionStatus::Unknown => Some(tr!( - "No Logitech device detected. Connect your device or verify \ - the hidraw udev rules are installed." - )), - PermissionStatus::Granted => None, - }; - if let Some(text) = hint { - field.child(div().text_xs().text_color(pal.text_muted).child(text)) - } else { - field - } - }), - ) - })); - - page -} - -#[cfg(target_os = "macos")] -fn permission_item( - id: &'static str, - title: SharedString, - description: SharedString, - permission: Permission, - status: impl Fn(&App) -> PermissionStatus + 'static, - pal: Palette, -) -> SettingItem { - SettingItem::new( - title, - SettingField::render(move |_, _, cx| permission_field(id, status(cx), permission, pal)), - ) - .description(description) -} - -fn assets_page(pal: Palette, cache_desc: SharedString) -> SettingPage { - let group = SettingGroup::new() - .item( - SettingItem::new( - tr!("Automatically download device images"), - SettingField::switch( - |cx| { - cx.try_global::() - .is_none_or(|s| s.app_settings().auto_download_assets) - }, - |enabled, cx| { - cx.update_global::(move |s, _| { - s.set_auto_download_assets(enabled); - }); - // Re-enabling should fetch right away, not wait for the - // next device event. - if enabled { - send_asset_command(cx, AssetCommand::Refresh); - } - cx.refresh_windows(); - }, - ), - ) - .description(tr!( - "Fetch device renders from assets.openlogi.org when a device connects. When off, OpenLogi makes no asset network requests; bundled art and the silhouette still show." - )), - ) - .item( - SettingItem::new( - tr!("Refresh assets"), - SettingField::render(move |_, _, _| { - action_button("assets-refresh", tr!("Refresh"), pal, |cx| { - send_asset_command(cx, AssetCommand::Refresh); + .default_selected_index(SelectIndex { + page_ix: self.initial_page.index(), + group_ix: None, }) - }), + .page(general::general_page(self.sensitivity_slider.clone())) + .page(updates::updates_page(self.updater.clone(), pal)) + .page(permissions::permissions_page(pal)) + .page(appearance::appearance_page( + view.clone(), + self.theme_filter, + self.theme_search.clone(), + self.language_select.clone(), + pal, + )) + .page(assets::assets_page(pal, self.asset_cache_desc.clone())) + .page(about::about_page(view, self.copied, pal)), ) - .description(tr!("Re-download images for the connected devices now.")), - ) - .item( - SettingItem::new( - tr!("Clear cache"), - SettingField::render(move |_, _, _| { - action_button("assets-clear", tr!("Clear"), pal, |cx| { - send_asset_command(cx, AssetCommand::ClearCache); - cx.refresh_windows(); - }) - }), - ) - .description(cache_desc), - ) - .item( - SettingItem::new( - tr!("Cache location"), - SettingField::render(move |_, _, _| { - action_button("assets-open", tr!("Open"), pal, |_| { - crate::asset::reveal_cache_in_file_manager(); - }) - }), - ) - .description(tr!("Show the downloaded-images folder in your file manager.")), - ); - - SettingPage::new(tr!("Assets")) - .icon(IconName::HardDrive) - .resettable(false) - .group(group) -} - -/// Human-readable size of the on-disk asset cache, for the "Clear cache" row. -/// Computed once when the Settings window opens (`asset_cache_desc`), not per -/// render. -fn cache_size_description() -> SharedString { - #[allow( - clippy::cast_precision_loss, - reason = "the cache is at most a few hundred MB; f64 is exact far past that, \ - and this is a display-only size" - )] - let mb = crate::asset::cache_size_bytes() as f64 / 1024.0 / 1024.0; - tr!("Downloaded images currently use %{size}.", size => format!("{mb:.1} MB")) -} - -/// A small bordered text button matching the permission rows' "Open" control. -fn action_button( - id: &'static str, - label: SharedString, - pal: Palette, - on_click: impl Fn(&mut App) + 'static, -) -> impl IntoElement { - div() - .id(id) - .flex_shrink_0() - .px_2() - .py_1() - .rounded_md() - .border_1() - .border_color(pal.border) - .text_xs() - .cursor_pointer() - .hover(move |s| s.bg(pal.surface_hover)) - .child(label) - .on_click(move |_, _, cx| on_click(cx)) -} - -/// Push a manual asset action to the main loop's [`AssetControl`] channel. -fn send_asset_command(cx: &App, cmd: AssetCommand) { - if let Some(ctrl) = cx.try_global::() { - let _ = ctrl.0.send(cmd); } } - -fn language_page(language_select: Entity>>) -> SettingPage { - SettingPage::new(tr!("Language")) - .icon(IconName::Globe) - .resettable(false) - .group( - SettingGroup::new().item( - SettingItem::new( - tr!("Language"), - SettingField::render(move |_, _, _| { - language_select_field(language_select.clone()) - }), - ) - .description(tr!("Choose the interface language.")), - ), - ) -} - -#[derive(Clone)] -struct LanguageOption { - label: &'static str, - value: &'static str, - localize_label: bool, -} - -impl SelectItem for LanguageOption { - type Value = &'static str; - - fn title(&self) -> SharedString { - if self.localize_label { - SharedString::from(rust_i18n::t!("Follow system").into_owned()) - } else { - SharedString::from(self.label) - } - } - - fn value(&self) -> &Self::Value { - &self.value - } -} - -fn language_options() -> Vec { - let mut options = vec![LanguageOption { - label: "Follow system", - value: "", - localize_label: true, - }]; - options.extend( - crate::i18n::SUPPORTED - .iter() - .map(|(code, name)| LanguageOption { - label: name, - value: code, - localize_label: false, - }), - ); - options -} - -fn selected_language_index(current: Option<&str>, options: &[LanguageOption]) -> IndexPath { - let value = current.unwrap_or_default(); - let row = options - .iter() - .position(|option| option.value == value) - .unwrap_or_default(); - IndexPath::default().row(row) -} - -/// A coloured status word for a permission row. -#[cfg(any(target_os = "macos", target_os = "linux"))] -fn status_badge(status: PermissionStatus) -> impl IntoElement { - let (label, color) = match status { - PermissionStatus::Granted => (tr!("Granted"), theme::STATUS_CONNECTED), - PermissionStatus::Denied => (tr!("Not granted"), theme::STATUS_CONNECTING), - PermissionStatus::Unknown => (tr!("Unknown"), theme::STATUS_OFFLINE), - }; - div().text_xs().text_color(rgb(color)).child(label) -} - -/// The right-side field for one permission row: live status, plus (macOS only) -/// an "Open" button that deep-links to the relevant System Settings pane. -#[cfg(target_os = "macos")] -fn permission_field( - id: &'static str, - status: PermissionStatus, - permission: Permission, - pal: Palette, -) -> impl IntoElement { - let row = h_flex() - .flex_shrink_0() - .items_center() - .gap_3() - .child(status_badge(status)); - - #[cfg(target_os = "macos")] - let row = row.child( - div() - .id(id) - .px_2() - .py_1() - .rounded_md() - .border_1() - .border_color(pal.border) - .text_xs() - .cursor_pointer() - .hover(move |s| s.bg(pal.surface_hover)) - .child(tr!("Open")) - .on_click(move |_, _, cx| { - // Accessibility must be prompted in the agent (it owns the - // hook); prompting in the GUI would authorize the wrong - // binary. Other panes just deep-link to System Settings. - if matches!(permission, Permission::Accessibility) - && let Some(state) = cx.try_global::() - { - state.request_accessibility_prompt(); - } - permissions::open_pane(permission); - }), - ); - - #[cfg(not(target_os = "macos"))] - let _ = (id, permission, pal); - - row -} - -/// The language picker field. "Follow system" clears the stored preference -/// (`None`); explicit locale entries come from [`crate::i18n::SUPPORTED`]. -#[allow( - clippy::needless_pass_by_value, - reason = "built inside an `Fn` render closure, so a `&Entity` parameter would make \ - the returned element borrow a captured variable; `Entity` is a cheap handle" -)] -fn language_select_field( - language_select: Entity>>, -) -> impl IntoElement { - // The Select's root is `size_full`, so pin it to a fixed-size box instead - // of letting it consume the whole Settings item row. - div().flex_shrink_0().w(px(220.)).h_6().child( - Select::new(&language_select) - .small() - .w(px(220.)) - .menu_width(px(220.)), - ) -} - -/// The thumb-wheel sensitivity field: the slider plus a live value readout that -/// flags the 1× default. Reads the slider entity directly so the readout tracks -/// the drag; persistence is handled by [`SettingsView::on_sensitivity_slider`]. -#[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "slider value is a stepped 1..=100 figure" -)] -fn sensitivity_field(slider: &Entity, cx: &mut App) -> AnyElement { - let value = slider.read(cx).value().start().round() as i32; - let is_default = value == DEFAULT_THUMBWHEEL_SENSITIVITY; - let pal = theme::palette(cx); - v_flex() - .flex_shrink_0() - .gap_1() - .child( - h_flex() - .items_center() - .gap_3() - .child(div().w(px(180.)).child(Slider::new(slider))) - .child( - div() - .w(px(72.)) - .text_sm() - .text_color(pal.text_muted) - .child(value.to_string()), - ), - ) - .when(is_default, |this| { - this.child( - div() - .text_xs() - .text_color(pal.text_muted) - .whitespace_nowrap() - .child(format!("({})", rust_i18n::t!("Default"))), - ) - }) - .into_any_element() -} diff --git a/crates/openlogi-gui/src/windows/settings/about.rs b/crates/openlogi-gui/src/windows/settings/about.rs new file mode 100644 index 00000000..c621a2d4 --- /dev/null +++ b/crates/openlogi-gui/src/windows/settings/about.rs @@ -0,0 +1,186 @@ +//! About settings page. + +use super::{ + AnyElement, App, Button, ButtonVariants, ClipboardItem, Entity, FontWeight, HELP_URL, Icon, + IconName, IntoElement, Palette, ParentElement, RELEASES_URL, REPO_URL, SettingGroup, + SettingItem, SettingPage, SettingsView, SharedString, Sizable, Styled, div, h_flex, img, px, + v_flex, +}; + +/// The About page: a hero card with the build identity and outbound links, the +/// on-disk config location, and a trademark disclaimer. +pub(super) fn about_page(view: Entity, copied: bool, pal: Palette) -> SettingPage { + let hero = SettingGroup::new().item(SettingItem::render(move |_, _, cx| { + about_hero(&view, copied, pal, cx) + })); + let config = SettingGroup::new().item(SettingItem::render(move |_, _, _| about_config(pal))); + let footer = SettingGroup::new().item(SettingItem::render(move |_, _, _| { + div() + .text_xs() + .text_color(pal.text_muted) + .child(tr!( + "Not affiliated with Logitech. \"Logitech\", \"MX Master\", and \"Options+\" are trademarks of Logitech International S.A." + )) + .into_any_element() + })); + + SettingPage::new(tr!("About")) + .icon(IconName::Info) + .resettable(false) + .description(tr!( + "A native, local-first alternative to Logitech Options+." + )) + .group(hero) + .group(config) + .group(footer) +} + +/// The About hero row: logo, wordmark, the clickable build line, and the link / +/// diagnostics buttons. +fn about_hero(view: &Entity, copied: bool, pal: Palette, _: &mut App) -> AnyElement { + let diag_label = if copied { + tr!("Copied!") + } else { + tr!("Copy Diagnostics") + }; + let view = view.clone(); + + h_flex() + .w_full() + .items_start() + .gap_3() + .child(img(crate::app_assets::LOGO).w(px(56.)).h(px(56.))) + .child( + v_flex() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .text_lg() + .font_weight(FontWeight::BOLD) + .child("OpenLogi"), + ) + .child( + div() + .text_sm() + .text_color(pal.text_muted) + .child(env!("CARGO_PKG_VERSION")), + ), + ) + .child( + h_flex() + .items_center() + .gap_1() + .pt_1() + .child(link_button( + "about-repo", + Icon::new(IconName::Github), + tr!("GitHub"), + REPO_URL, + )) + .child(link_button( + "about-changelog", + Icon::empty().path("action-icons/scroll-text.svg"), + tr!("Changelog"), + RELEASES_URL, + )) + .child(link_button( + "about-docs", + Icon::new(IconName::BookOpen), + tr!("Documentation"), + HELP_URL, + )) + .child(link_button( + "about-issue", + Icon::empty().path("action-icons/bug.svg"), + tr!("Report an issue"), + format!("{REPO_URL}/issues"), + )) + .child(div().w(px(1.)).h(px(16.)).mx_1().bg(pal.border)) + .child( + Button::new("about-copy-diagnostics") + .ghost() + .small() + .icon(IconName::Copy) + .label(diag_label) + .on_click(move |_, _, cx| { + let report = crate::diagnostics::collect(cx).to_markdown(); + cx.write_to_clipboard(ClipboardItem::new_string(report)); + view.update(cx, |this, cx| { + this.copied = true; + this.copied_gen = this.copied_gen.wrapping_add(1); + let generation = this.copied_gen; + cx.notify(); + cx.spawn(async move |handle, cx| { + cx.background_executor() + .timer(std::time::Duration::from_secs(2)) + .await; + handle + .update(cx, |this, cx| { + if this.copied_gen == generation { + this.copied = false; + cx.notify(); + } + }) + .ok(); + }) + .detach(); + }); + }), + ), + ), + ) + .into_any_element() +} + +/// The config-file location row with a reveal-in-file-manager button. +fn about_config(pal: Palette) -> AnyElement { + let path = openlogi_core::paths::config_path() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().font_weight(FontWeight::MEDIUM).child("config.toml")) + .child(div().text_xs().text_color(pal.text_muted).child(path)), + ) + .child( + Button::new("about-reveal-config") + .outline() + .label(tr!("Show in file manager")) + .on_click(|_, _, cx| { + if let Ok(dir) = openlogi_core::paths::config_dir() + && let Ok(url) = url::Url::from_file_path(&dir) + { + cx.open_url(url.as_str()); + } + }), + ) + .into_any_element() +} + +/// A subtle ghost button with a leading icon that opens `href`, used for the +/// About link row. +fn link_button( + id: &'static str, + icon: Icon, + label: SharedString, + href: impl Into, +) -> Button { + let href = href.into(); + Button::new(id) + .ghost() + .small() + .icon(icon) + .label(label) + .on_click(move |_, _, cx| cx.open_url(&href)) +} diff --git a/crates/openlogi-gui/src/windows/settings/appearance.rs b/crates/openlogi-gui/src/windows/settings/appearance.rs new file mode 100644 index 00000000..a4d5766b --- /dev/null +++ b/crates/openlogi-gui/src/windows/settings/appearance.rs @@ -0,0 +1,563 @@ +//! Appearance settings page: mode, theme grid, radius, language. + +use super::language::{LanguageOption, language_select_field}; +use super::{ + ActiveTheme, AnyElement, App, AppState, Appearance, Axis, BorrowAppContext, Button, + ButtonGroup, Entity, FluentBuilder, Hsla, IconName, Input, InputState, InteractiveElement, + IntoElement, Palette, ParentElement, Rc, SelectState, Selectable, SettingField, SettingGroup, + SettingItem, SettingPage, SettingsView, SharedString, Sizable, StatefulInteractiveElement, + Styled, Theme, ThemeColor, ThemeConfig, ThemeFilter, ThemeMode, ThemeRegistry, div, h_flex, px, + rgb, theme, v_flex, +}; + +/// The Appearance page: light/dark mode, the theme grid, corner radius, and the +/// interface language. Every theme here re-skins the whole app — the bespoke +/// surfaces read the same `cx.theme()` tokens as the framework widgets. +pub(super) fn appearance_page( + view: Entity, + filter: ThemeFilter, + theme_search: Entity, + language_select: Entity>>, + pal: Palette, +) -> SettingPage { + // Titled groups so the sidebar shows them as sub-items (gpui-component + // renders a page's groups as nested sidebar entries once there's more than + // one and each is titled). Item titles stay distinct from their group title. + let theme_group = SettingGroup::new() + .title(tr!("Theme")) + .item( + SettingItem::new( + tr!("Appearance mode"), + SettingField::render(move |_, _, cx| mode_segment(pal, cx)), + ) + .layout(Axis::Vertical) + .description(tr!( + "Light and dark use the matching theme; Follow system tracks the OS setting." + )), + ) + .item( + SettingItem::new( + tr!("Color theme"), + SettingField::render(move |_, _, cx| { + theme_picker(&view, &theme_search, filter, pal, cx) + }), + ) + .layout(Axis::Vertical), + ) + .item( + // Compact control → inline on the right of the label (HIG), unlike + // the wide thumbnail/grid controls which stack below. + SettingItem::new( + tr!("Corner radius"), + SettingField::render(move |_, _, cx| radius_segment(cx)), + ) + .description(tr!("Roundness of buttons, cards, and controls.")), + ); + + let language_group = SettingGroup::new().title(tr!("Language")).item( + SettingItem::new( + tr!("Interface language"), + SettingField::render(move |_, _, _| language_select_field(language_select.clone())), + ) + .description(tr!("Choose the interface language.")), + ); + + SettingPage::new(tr!("Appearance")) + .icon(IconName::Palette) + .resettable(false) + .group(language_group) + .group(theme_group) +} + +/// The stored light/dark preference (defaults to following the OS). +fn appearance_of(cx: &App) -> Appearance { + cx.try_global::() + .map_or(Appearance::System, |s| s.app_settings().appearance) +} + +/// Persist an appearance-mode choice and re-apply the live theme. +fn set_appearance(cx: &mut App, appearance: Appearance) { + cx.update_global::(|s, _| s.set_appearance(appearance)); + theme::apply_from_settings(None, cx); +} + +/// Persist a corner-radius choice and re-apply the live theme. `None` defers to +/// the active theme's own radius. +fn set_radius(cx: &mut App, radius: Option) { + cx.update_global::(|s, _| s.set_ui_radius(radius)); + theme::apply_from_settings(None, cx); +} + +/// The Light / Dark / Follow-system appearance picker — three macOS-style +/// preview thumbnails, each with a radio + label, mirroring System Settings. +fn mode_segment(pal: Palette, cx: &App) -> AnyElement { + let current = appearance_of(cx); + let accent = cx.theme().primary; + h_flex() + .gap_4() + .items_start() + .child(mode_card( + "mode-light", + tr!("Light"), + ModePreview::Light, + current == Appearance::Light, + accent, + pal, + |cx| set_appearance(cx, Appearance::Light), + )) + .child(mode_card( + "mode-dark", + tr!("Dark"), + ModePreview::Dark, + current == Appearance::Dark, + accent, + pal, + |cx| set_appearance(cx, Appearance::Dark), + )) + .child(mode_card( + "mode-system", + tr!("Follow system"), + ModePreview::Auto, + current == Appearance::System, + accent, + pal, + |cx| set_appearance(cx, Appearance::System), + )) + .into_any_element() +} + +/// Which scheme a mode card's thumbnail paints. +#[derive(Clone, Copy)] +enum ModePreview { + Light, + Dark, + Auto, +} + +/// One appearance card: a preview thumbnail (with an accent selection ring when +/// active) above a radio dot + label. +fn mode_card( + id: &'static str, + label: SharedString, + preview: ModePreview, + selected: bool, + accent: Hsla, + pal: Palette, + on_click: impl Fn(&mut App) + 'static, +) -> impl IntoElement { + let thumb = div() + .w(px(104.)) + .h(px(64.)) + .rounded_lg() + .overflow_hidden() + .border_2() + .border_color(if selected { accent } else { pal.border }) + .map(|this| match preview { + ModePreview::Light => this.child(mini_window(false)), + ModePreview::Dark => this.child(mini_window(true)), + // A single window split down the middle: light left, dark right. + ModePreview::Auto => this.child( + h_flex() + .size_full() + .child( + div() + .w(px(50.)) + .h_full() + .overflow_hidden() + .child(mini_window(false)), + ) + .child( + div() + .w(px(50.)) + .h_full() + .flex() + .justify_end() + .overflow_hidden() + .child(mini_window(true)), + ), + ), + }); + + v_flex() + .id(id) + .gap(px(6.)) + .items_center() + .cursor_pointer() + .child(thumb) + .child( + h_flex() + .items_center() + .gap(px(6.)) + .child(radio_dot(selected, accent, pal)) + .child(div().text_sm().child(label)), + ) + .on_click(move |_, _, cx| on_click(cx)) +} + +/// A miniature window-on-desktop at a fixed 100×60, used inside a mode card. +/// Painted with fixed scheme colours (representing light vs dark) so the +/// thumbnails read the same under any active theme. +fn mini_window(dark: bool) -> impl IntoElement { + let (wallpaper, window, bar, line) = if dark { + ( + rgb(0x26_262b), + rgb(0x1b_1b1e), + rgb(0x3a_3a42), + rgb(0x3b_82f6), + ) + } else { + ( + rgb(0xdf_e4ec), + rgb(0xff_ffff), + rgb(0xcc_d2db), + rgb(0x3b_82f6), + ) + }; + let dot = |c: u32| div().size(px(3.)).rounded_full().bg(rgb(c)); + div() + .w(px(100.)) + .h(px(60.)) + .flex_shrink_0() + .bg(wallpaper) + .p(px(7.)) + .child( + v_flex() + .size_full() + .rounded(px(4.)) + .overflow_hidden() + .bg(window) + .child( + h_flex() + .h(px(11.)) + .w_full() + .items_center() + .gap(px(2.)) + .px(px(4.)) + .bg(bar) + .child(dot(0xff_5f57)) + .child(dot(0xfe_bc2e)) + .child(dot(0x28_c840)), + ) + .child( + v_flex() + .p(px(5.)) + .gap(px(3.)) + .child(div().w(px(30.)).h(px(3.)).rounded_full().bg(line)) + .child(div().w(px(54.)).h(px(3.)).rounded_full().bg(bar)) + .child(div().w(px(40.)).h(px(3.)).rounded_full().bg(bar)), + ), + ) +} + +/// A small radio indicator: a ring when unselected, a filled accent dot when +/// selected. +fn radio_dot(selected: bool, accent: Hsla, pal: Palette) -> impl IntoElement { + div() + .size(px(13.)) + .rounded_full() + .border_2() + .flex() + .items_center() + .justify_center() + .border_color(if selected { accent } else { pal.text_muted }) + .when(selected, |this| { + this.child(div().size(px(6.)).rounded_full().bg(accent)) + }) +} + +/// The Sharp / Default / Round corner-radius segmented control. "Default" stores +/// `None` — defer to the active theme's own radius — rather than a fixed 6px, so +/// it neither mis-highlights under themes with a different radius nor traps the +/// user away from the theme default. +fn radius_segment(cx: &App) -> AnyElement { + let current = cx + .try_global::() + .and_then(|s| s.app_settings().ui_radius); + let options: [Option; 3] = [Some(0), None, Some(12)]; + ButtonGroup::new("corner-radius") + .outline() + .child( + Button::new("radius-sharp") + .label(tr!("Sharp")) + .selected(current == Some(0)), + ) + .child( + Button::new("radius-default") + .label(tr!("Default")) + .selected(current.is_none()), + ) + .child( + Button::new("radius-round") + .label(tr!("Round")) + .selected(current == Some(12)), + ) + .on_click(move |clicks, _, cx| { + if let Some(&ix) = clicks.first() { + set_radius(cx, options[ix]); + } + }) + .into_any_element() +} + +/// Filter chips + the theme grid. Each card previews the theme's own colours +/// and, on click, stores it for the matching mode and switches to that mode. +fn theme_picker( + view: &Entity, + theme_search: &Entity, + filter: ThemeFilter, + pal: Palette, + cx: &App, +) -> AnyElement { + let active = cx.theme().theme_name().clone(); + let query = theme_search.read(cx).value().trim().to_lowercase(); + // Collect just the preview colours per theme (small + `Copy`), so the 1.8 KB + // `ThemeColor` isn't held across the element build. + let themes: Vec<(SharedString, ThemeMode, Swatch)> = { + let registry = ThemeRegistry::global(cx); + registry + .sorted_themes() + .into_iter() + .filter(|cfg| match filter { + ThemeFilter::All => true, + ThemeFilter::Light => !cfg.mode.is_dark(), + ThemeFilter::Dark => cfg.mode.is_dark(), + }) + .filter(|cfg| query.is_empty() || cfg.name.to_lowercase().contains(&query)) + .map(|cfg| { + let colors = resolved_colors(cfg); + let swatch = Swatch { + bg: colors.background, + primary: colors.primary, + foreground: colors.foreground, + }; + (cfg.name.clone(), cfg.mode, swatch) + }) + .collect() + }; + + let grid = if themes.is_empty() { + div() + .text_sm() + .text_color(pal.text_muted) + .child(tr!("No themes match “%{query}”.", query => query)) + .into_any_element() + } else { + div() + .flex() + .flex_wrap() + .gap_2() + .children( + themes + .into_iter() + .enumerate() + .map(|(i, (name, mode, swatch))| { + let selected = name == active; + theme_card(i, name, mode, swatch, selected, pal) + }), + ) + .into_any_element() + }; + + v_flex() + .w_full() + .gap_3() + .child( + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_3() + .child( + h_flex() + .gap_2() + .child(filter_chip( + view, + "filter-all", + tr!("All"), + ThemeFilter::All, + filter, + pal, + )) + .child(filter_chip( + view, + "filter-light", + tr!("Light"), + ThemeFilter::Light, + filter, + pal, + )) + .child(filter_chip( + view, + "filter-dark", + tr!("Dark"), + ThemeFilter::Dark, + filter, + pal, + )), + ) + .child( + div().w(px(200.)).child( + Input::new(theme_search) + .small() + .cleanable(true) + .prefix(IconName::Search), + ), + ), + ) + .child(grid) + .into_any_element() +} + +/// Resolve a theme config's colours into a concrete [`ThemeColor`] for its +/// preview, without touching the global theme (mirrors gpui-component's own +/// theme picker). +fn resolved_colors(cfg: &Rc) -> ThemeColor { + let base = if cfg.mode.is_dark() { + ThemeColor::dark() + } else { + ThemeColor::light() + }; + let mut temp = Theme::from(base.as_ref()); + temp.apply_config(cfg); + temp.colors +} + +/// One theme card: a mini preview, the name, and a light/dark badge. +/// The three resolved colours a theme card previews. Small + `Copy` so it can +/// be collected and passed by value. +#[derive(Clone, Copy)] +struct Swatch { + bg: Hsla, + primary: Hsla, + foreground: Hsla, +} + +fn theme_card( + index: usize, + name: SharedString, + mode: ThemeMode, + swatch: Swatch, + selected: bool, + pal: Palette, +) -> impl IntoElement { + let dark = mode.is_dark(); + let stored = name.clone(); + v_flex() + .id(SharedString::from(format!("theme-{index}"))) + .w(px(132.)) + .p(px(8.)) + .gap_2() + .rounded_lg() + .border_1() + .border_color(if selected { swatch.primary } else { pal.border }) + .bg(pal.surface) + .cursor_pointer() + .when(!selected, |this| { + this.hover(|h| h.border_color(pal.text_muted)) + }) + .child( + v_flex() + .h(px(54.)) + .w_full() + .rounded_md() + .overflow_hidden() + .p(px(7.)) + .gap(px(4.)) + .bg(swatch.bg) + .child(div().w(px(40.)).h(px(4.)).rounded_full().bg(swatch.primary)) + .child( + div() + .w(px(66.)) + .h(px(4.)) + .rounded_full() + .bg(swatch.foreground) + .opacity(0.7), + ) + .child( + div() + .w(px(34.)) + .h(px(4.)) + .rounded_full() + .bg(swatch.foreground) + .opacity(0.4), + ), + ) + .child( + h_flex() + .items_center() + .justify_between() + .gap_1() + .child( + div() + .overflow_hidden() + .text_xs() + .text_color(pal.text_primary) + .child(name), + ) + .child( + div() + .flex_shrink_0() + .text_size(px(9.)) + .text_color(pal.text_muted) + .child(if dark { tr!("Dark") } else { tr!("Light") }), + ), + ) + .on_click(move |_, _, cx| { + let chosen = stored.to_string(); + cx.update_global::(move |s, _| { + s.set_theme(dark, Some(chosen.clone())); + // Picking a theme configures the light or dark *slot*. Only pin + // the mode when the user has already chosen an explicit + // Light/Dark mode — a "Follow System" preference must survive so + // configuring (say) the dark slot doesn't force the whole app to + // dark. + if s.app_settings().appearance != Appearance::System { + s.set_appearance(if dark { + Appearance::Dark + } else { + Appearance::Light + }); + } + }); + theme::apply_from_settings(None, cx); + }) +} + +/// A pill that filters the theme grid by mode. View-local; clicking just +/// re-renders with the new filter. +fn filter_chip( + view: &Entity, + id: &'static str, + label: SharedString, + value: ThemeFilter, + current: ThemeFilter, + pal: Palette, +) -> impl IntoElement { + let selected = value == current; + let view = view.clone(); + div() + .id(id) + .px_3() + .py_1() + .rounded_full() + .border_1() + .text_xs() + .cursor_pointer() + .map(|this| { + if selected { + this.border_color(pal.text_primary) + .text_color(pal.text_primary) + } else { + this.border_color(pal.border) + .text_color(pal.text_muted) + .hover(|h| h.border_color(pal.text_muted)) + } + }) + .child(label) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.theme_filter = value; + cx.notify(); + }); + }) +} diff --git a/crates/openlogi-gui/src/windows/settings/assets.rs b/crates/openlogi-gui/src/windows/settings/assets.rs new file mode 100644 index 00000000..8df1866c --- /dev/null +++ b/crates/openlogi-gui/src/windows/settings/assets.rs @@ -0,0 +1,117 @@ +//! Assets (device-image cache) settings page. + +use super::{ + App, AppState, AssetCommand, AssetControl, BorrowAppContext, IconName, InteractiveElement, + IntoElement, Palette, ParentElement, SettingField, SettingGroup, SettingItem, SettingPage, + SharedString, StatefulInteractiveElement, Styled, div, +}; + +pub(super) fn assets_page(pal: Palette, cache_desc: SharedString) -> SettingPage { + let group = SettingGroup::new() + .item( + SettingItem::new( + tr!("Automatically download device images"), + SettingField::switch( + |cx| { + cx.try_global::() + .is_none_or(|s| s.app_settings().auto_download_assets) + }, + |enabled, cx| { + cx.update_global::(move |s, _| { + s.set_auto_download_assets(enabled); + }); + // Re-enabling should fetch right away, not wait for the + // next device event. + if enabled { + send_asset_command(cx, AssetCommand::Refresh); + } + cx.refresh_windows(); + }, + ), + ) + .description(tr!( + "Fetch device renders from assets.openlogi.org when a device connects. When off, OpenLogi makes no asset network requests; bundled art and the silhouette still show." + )), + ) + .item( + SettingItem::new( + tr!("Refresh assets"), + SettingField::render(move |_, _, _| { + action_button("assets-refresh", tr!("Refresh"), pal, |cx| { + send_asset_command(cx, AssetCommand::Refresh); + }) + }), + ) + .description(tr!("Re-download images for the connected devices now.")), + ) + .item( + SettingItem::new( + tr!("Clear cache"), + SettingField::render(move |_, _, _| { + action_button("assets-clear", tr!("Clear"), pal, |cx| { + send_asset_command(cx, AssetCommand::ClearCache); + cx.refresh_windows(); + }) + }), + ) + .description(cache_desc), + ) + .item( + SettingItem::new( + tr!("Cache location"), + SettingField::render(move |_, _, _| { + action_button("assets-open", tr!("Open"), pal, |_| { + crate::asset::reveal_cache_in_file_manager(); + }) + }), + ) + .description(tr!("Show the downloaded-images folder in your file manager.")), + ); + + SettingPage::new(tr!("Assets")) + .icon(IconName::HardDrive) + .resettable(false) + .group(group) +} + +/// Human-readable size of the on-disk asset cache, for the "Clear cache" row. +/// Computed once when the Settings window opens (`asset_cache_desc`), not per +/// render. +pub(super) fn cache_size_description() -> SharedString { + #[allow( + clippy::cast_precision_loss, + reason = "the cache is at most a few hundred MB; f64 is exact far past that, \ + and this is a display-only size" + )] + let mb = crate::asset::cache_size_bytes() as f64 / 1024.0 / 1024.0; + tr!("Downloaded images currently use %{size}.", size => format!("{mb:.1} MB")) +} + +/// A small bordered text button matching the permission rows' "Open" control. +fn action_button( + id: &'static str, + label: SharedString, + pal: Palette, + on_click: impl Fn(&mut App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .flex_shrink_0() + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(pal.border) + .text_xs() + .cursor_pointer() + .hover(move |s| s.bg(pal.surface_hover)) + .child(label) + .on_click(move |_, _, cx| on_click(cx)) +} + +/// Push a manual asset action to the main loop's [`AssetControl`] channel. +fn send_asset_command(cx: &App, cmd: AssetCommand) { + if let Some(ctrl) = cx.try_global::() { + let _ = ctrl.0.send(cmd); + } +} diff --git a/crates/openlogi-gui/src/windows/settings/general.rs b/crates/openlogi-gui/src/windows/settings/general.rs new file mode 100644 index 00000000..7b6adc78 --- /dev/null +++ b/crates/openlogi-gui/src/windows/settings/general.rs @@ -0,0 +1,106 @@ +//! General settings page. + +use super::{ + AnyElement, App, AppState, BorrowAppContext, DEFAULT_THUMBWHEEL_SENSITIVITY, Entity, + FluentBuilder, IconName, IntoElement, ParentElement, SettingField, SettingGroup, SettingItem, + SettingPage, Slider, SliderState, Styled, div, h_flex, px, theme, v_flex, +}; + +pub(super) fn general_page(sensitivity_slider: Entity) -> SettingPage { + let group = SettingGroup::new() + .item( + SettingItem::new( + tr!("Thumb Wheel Sensitivity"), + SettingField::render(move |_, _, cx| { + sensitivity_field(&sensitivity_slider, cx) + }), + ) + .description(tr!( + "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger." + )), + ) + .item( + SettingItem::new( + tr!("Launch at login"), + SettingField::switch( + |cx| { + cx.try_global::() + .is_some_and(|s| s.app_settings().launch_at_login) + }, + |enabled, cx| { + cx.update_global::(move |s, _| { + s.set_launch_at_login(enabled); + }); + cx.refresh_windows(); + }, + ), + ) + .description(tr!( + "Automatically start OpenLogi when you log in." + )), + ); + + #[cfg(target_os = "macos")] + let group = group.item( + SettingItem::new( + tr!("Show in menu bar"), + SettingField::switch( + |cx| { + cx.try_global::() + .is_some_and(|s| s.app_settings().show_in_menu_bar) + }, + |enabled, cx| { + cx.update_global::(move |s, _| { + s.set_show_in_menu_bar(enabled); + }); + cx.refresh_windows(); + }, + ), + ) + .description(tr!( + "Keep OpenLogi's icon in the menu bar. When off, it stays in the Dock instead." + )), + ); + + SettingPage::new(tr!("General")) + .icon(IconName::Settings) + .resettable(false) + .group(group) +} + +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "slider value is a stepped 1..=100 figure" +)] +fn sensitivity_field(slider: &Entity, cx: &mut App) -> AnyElement { + let value = slider.read(cx).value().start().round() as i32; + let is_default = value == DEFAULT_THUMBWHEEL_SENSITIVITY; + let pal = theme::palette(cx); + v_flex() + .flex_shrink_0() + .gap_1() + .child( + h_flex() + .items_center() + .gap_3() + .child(div().w(px(180.)).child(Slider::new(slider))) + .child( + div() + .w(px(72.)) + .text_sm() + .text_color(pal.text_muted) + .child(value.to_string()), + ), + ) + .when(is_default, |this| { + this.child( + div() + .text_xs() + .text_color(pal.text_muted) + .whitespace_nowrap() + .child(format!("({})", rust_i18n::t!("Default"))), + ) + }) + .into_any_element() +} diff --git a/crates/openlogi-gui/src/windows/settings/language.rs b/crates/openlogi-gui/src/windows/settings/language.rs new file mode 100644 index 00000000..c96d9d0f --- /dev/null +++ b/crates/openlogi-gui/src/windows/settings/language.rs @@ -0,0 +1,79 @@ +//! Interface-language picker, shared by the Appearance page and the view. + +use super::{ + Entity, IndexPath, IntoElement, ParentElement, Select, SelectItem, SelectState, SharedString, + Sizable, Styled, div, px, +}; + +#[derive(Clone)] +pub(super) struct LanguageOption { + label: &'static str, + value: &'static str, + localize_label: bool, +} + +impl SelectItem for LanguageOption { + type Value = &'static str; + + fn title(&self) -> SharedString { + if self.localize_label { + SharedString::from(rust_i18n::t!("Follow system").into_owned()) + } else { + SharedString::from(self.label) + } + } + + fn value(&self) -> &Self::Value { + &self.value + } +} + +pub(super) fn language_options() -> Vec { + let mut options = vec![LanguageOption { + label: "Follow system", + value: "", + localize_label: true, + }]; + options.extend( + crate::i18n::SUPPORTED + .iter() + .map(|(code, name)| LanguageOption { + label: name, + value: code, + localize_label: false, + }), + ); + options +} + +pub(super) fn selected_language_index( + current: Option<&str>, + options: &[LanguageOption], +) -> IndexPath { + let value = current.unwrap_or_default(); + let row = options + .iter() + .position(|option| option.value == value) + .unwrap_or_default(); + IndexPath::default().row(row) +} + +/// The language picker field. "Follow system" clears the stored preference +/// (`None`); explicit locale entries come from [`crate::i18n::SUPPORTED`]. +#[allow( + clippy::needless_pass_by_value, + reason = "built inside an `Fn` render closure, so a `&Entity` parameter would make \ + the returned element borrow a captured variable; `Entity` is a cheap handle" +)] +pub(super) fn language_select_field( + language_select: Entity>>, +) -> impl IntoElement { + // The Select's root is `size_full`, so pin it to a fixed-size box instead + // of letting it consume the whole Settings item row. + div().flex_shrink_0().w(px(220.)).h_6().child( + Select::new(&language_select) + .small() + .w(px(220.)) + .menu_width(px(220.)), + ) +} diff --git a/crates/openlogi-gui/src/windows/settings/permissions.rs b/crates/openlogi-gui/src/windows/settings/permissions.rs new file mode 100644 index 00000000..1ebe8497 --- /dev/null +++ b/crates/openlogi-gui/src/windows/settings/permissions.rs @@ -0,0 +1,169 @@ +//! Permissions settings page (macOS / Linux). + +#[cfg(target_os = "macos")] +use super::{ + App, AppState, InteractiveElement, Permission, SharedString, StatefulInteractiveElement, h_flex, +}; +use super::{IconName, Palette, SettingPage}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use super::{ + IntoElement, ParentElement, PermissionStatus, SettingField, SettingGroup, SettingItem, Styled, + div, rgb, theme, +}; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use crate::platform::permissions; + +#[cfg_attr( + not(any(target_os = "macos", target_os = "linux")), + allow(unused_variables) +)] +pub(super) fn permissions_page(pal: Palette) -> SettingPage { + let page = SettingPage::new(tr!("Permissions")) + .icon(IconName::Info) + .resettable(false); + + #[cfg(target_os = "macos")] + let page = page.group( + SettingGroup::new() + .item(permission_item( + "perm-accessibility", + tr!("Accessibility"), + tr!("Needed for gesture and button remapping (event tap)."), + Permission::Accessibility, + |cx| { + // The agent owns the hook, so this is *its* grant, + // reported over IPC; while not connected the state is + // genuinely unknown, not denied. + match cx.try_global::().and_then(AppState::agent_status) { + Some(status) if status.accessibility_granted => PermissionStatus::Granted, + Some(_) => PermissionStatus::Denied, + None => PermissionStatus::Unknown, + } + }, + pal, + )) + .item(permission_item( + "perm-input-monitoring", + tr!("Input Monitoring"), + tr!("Needed to read HID++ data, including Bluetooth-direct mice."), + Permission::InputMonitoring, + |_| permissions::input_monitoring(), + pal, + )) + .item(permission_item( + "perm-bluetooth", + tr!("Bluetooth"), + tr!("Allows OpenLogi to use CoreBluetooth (not required for HID access)."), + Permission::Bluetooth, + |_| permissions::bluetooth(), + pal, + )), + ); + + #[cfg(target_os = "linux")] + let page = page.group(SettingGroup::new().item({ + // Description is only shown when access is not yet granted — no noise + // when everything is already working. + SettingItem::new( + tr!("Input device access"), + SettingField::render(move |_, _, _| { + let status = permissions::input_device_access(); + let field = gpui_component::v_flex().gap_1().child(status_badge(status)); + let hint = match status { + PermissionStatus::Denied => Some(tr!( + "OpenLogi needs write access to /dev/uinput (for button \ + remapping) and read/write access to /dev/hidraw* (for HID++ \ + communication). Install the OpenLogi udev rules to grant \ + access — see the Linux install guide." + )), + PermissionStatus::Unknown => Some(tr!( + "No Logitech device detected. Connect your device or verify \ + the hidraw udev rules are installed." + )), + PermissionStatus::Granted => None, + }; + if let Some(text) = hint { + field.child(div().text_xs().text_color(pal.text_muted).child(text)) + } else { + field + } + }), + ) + })); + + page +} + +#[cfg(target_os = "macos")] +fn permission_item( + id: &'static str, + title: SharedString, + description: SharedString, + permission: Permission, + status: impl Fn(&App) -> PermissionStatus + 'static, + pal: Palette, +) -> SettingItem { + SettingItem::new( + title, + SettingField::render(move |_, _, cx| permission_field(id, status(cx), permission, pal)), + ) + .description(description) +} + +/// A coloured status word for a permission row. +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn status_badge(status: PermissionStatus) -> impl IntoElement { + let (label, color) = match status { + PermissionStatus::Granted => (tr!("Granted"), theme::STATUS_CONNECTED), + PermissionStatus::Denied => (tr!("Not granted"), theme::STATUS_CONNECTING), + PermissionStatus::Unknown => (tr!("Unknown"), theme::STATUS_OFFLINE), + }; + div().text_xs().text_color(rgb(color)).child(label) +} + +/// The right-side field for one permission row: live status, plus (macOS only) +/// an "Open" button that deep-links to the relevant System Settings pane. +#[cfg(target_os = "macos")] +fn permission_field( + id: &'static str, + status: PermissionStatus, + permission: Permission, + pal: Palette, +) -> impl IntoElement { + let row = h_flex() + .flex_shrink_0() + .items_center() + .gap_3() + .child(status_badge(status)); + + #[cfg(target_os = "macos")] + let row = row.child( + div() + .id(id) + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(pal.border) + .text_xs() + .cursor_pointer() + .hover(move |s| s.bg(pal.surface_hover)) + .child(tr!("Open")) + .on_click(move |_, _, cx| { + // Accessibility must be prompted in the agent (it owns the + // hook); prompting in the GUI would authorize the wrong + // binary. Other panes just deep-link to System Settings. + if matches!(permission, Permission::Accessibility) + && let Some(state) = cx.try_global::() + { + state.request_accessibility_prompt(); + } + permissions::open_pane(permission); + }), + ); + + #[cfg(not(target_os = "macos"))] + let _ = (id, permission, pal); + + row +} diff --git a/crates/openlogi-gui/src/windows/settings/updates.rs b/crates/openlogi-gui/src/windows/settings/updates.rs new file mode 100644 index 00000000..4318aa8a --- /dev/null +++ b/crates/openlogi-gui/src/windows/settings/updates.rs @@ -0,0 +1,211 @@ +//! Updates settings page. + +use super::{ + AnyElement, App, AppState, BorrowAppContext, Button, ButtonVariants, Disableable, Entity, + FontWeight, IconName, IntoElement, Palette, ParentElement, RELEASES_URL, SettingField, + SettingGroup, SettingItem, SettingPage, Sizable, Styled, Tag, UpdateStatus, Updater, div, + h_flex, img, px, v_flex, +}; + +/// The Updates page: a hero card with the running build, its update status, and +/// the contextual check / install / restart action; the opt-in auto-check and +/// auto-install switches; and where updates come from. +pub(super) fn updates_page(updater: Entity, pal: Palette) -> SettingPage { + let hero = SettingGroup::new().item(SettingItem::render(move |_, _, cx| { + update_hero(&updater, pal, cx) + })); + + let toggles = SettingGroup::new() + .item( + SettingItem::new( + tr!("Check for updates"), + SettingField::switch( + |cx| { + cx.try_global::() + .is_some_and(|s| s.app_settings().check_for_updates) + }, + |enabled, cx| { + cx.update_global::(move |s, _| { + s.set_check_for_updates(enabled); + }); + cx.refresh_windows(); + }, + ), + ) + .description(tr!( + "Check once per launch for a new version (query only — no automatic download)." + )), + ) + .item( + SettingItem::new( + tr!("Automatically download and install"), + SettingField::switch( + |cx| { + cx.try_global::() + .is_some_and(|s| s.app_settings().auto_install_updates) + }, + |enabled, cx| { + cx.update_global::(move |s, _| { + s.set_auto_install_updates(enabled); + }); + cx.refresh_windows(); + }, + ), + ) + .description(tr!( + "Download updates in the background and apply them the next time OpenLogi restarts." + )), + ); + + let source = SettingGroup::new().item(SettingItem::render(move |_, _, _| update_source(pal))); + + SettingPage::new(tr!("Updates")) + .icon(IconName::ArrowDown) + .resettable(false) + .description(tr!( + "Off by default — checking for updates is OpenLogi's only optional outbound network request." + )) + .group(hero) + .group(toggles) + .group(source) +} + +/// The Updates hero row: logo, name + version, a status pill, the live status +/// message (or channel), and the one contextual action button. +fn update_hero(updater: &Entity, pal: Palette, cx: &mut App) -> AnyElement { + let status = updater.read(cx).status().clone(); + + // A short status tag for the settled states (semantic colours from the theme); + // transient states carry their detail in the message line instead. + let pill = match &status { + UpdateStatus::UpToDate => Some(Tag::success().child(tr!("Up to date"))), + UpdateStatus::Available(_) => Some(Tag::info().child(tr!("Update available"))), + UpdateStatus::Staged(_) => Some(Tag::success().child(tr!("Update ready"))), + UpdateStatus::Errored(_) => Some(Tag::danger().child(tr!("Update failed"))), + _ => None, + }; + + let message = match &status { + UpdateStatus::Idle | UpdateStatus::UpToDate => None, + UpdateStatus::Checking => Some(tr!("Checking for updates…")), + UpdateStatus::Available(v) => Some(tr!("Version %{version} is available.", version => v)), + UpdateStatus::Downloading { downloaded, total } => Some(match total { + Some(t) if *t > 0 => { + tr!("Downloading… %{percent}%", percent => (*downloaded * 100 / *t).to_string()) + } + _ => tr!("Downloading… %{size} MB", size => (*downloaded / 1_048_576).to_string()), + }), + UpdateStatus::Installing => Some(tr!("Installing…")), + UpdateStatus::Staged(v) => Some(tr!("Version %{version} is ready.", version => v)), + UpdateStatus::Errored(e) => Some(tr!("Update failed: %{error}", error => e.clone())), + }; + + let busy = matches!( + status, + UpdateStatus::Checking | UpdateStatus::Downloading { .. } | UpdateStatus::Installing + ); + + let action = { + let u = updater.clone(); + match &status { + UpdateStatus::Available(_) => Button::new("update-install") + .outline() + .label(tr!("Download & Install")) + .on_click(move |_, _, cx| { + u.update(cx, Updater::download_and_install); + }), + UpdateStatus::Staged(_) => Button::new("update-restart") + .outline() + .label(tr!("Restart to Update")) + .on_click(move |_, _, cx| { + u.update(cx, |u, cx| u.restart(cx)); + }), + _ => Button::new("update-check") + .outline() + .label(tr!("Check for Updates")) + .on_click(move |_, _, cx| { + u.update(cx, Updater::check); + }), + } + }; + + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_4() + .child( + h_flex() + .items_center() + .gap_3() + .child(img(crate::app_assets::LOGO).w(px(52.)).h(px(52.))) + .child( + v_flex() + .gap_1() + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(concat!("OpenLogi ", env!("CARGO_PKG_VERSION"))), + ) + .children(pill.map(|tag| tag.small().rounded_full())), + ) + .child( + div() + .text_xs() + .text_color(pal.text_muted) + .child(message.unwrap_or_else(|| tr!("Stable channel"))), + ), + ), + ) + .child(action.disabled(busy)) + .into_any_element() +} + +/// The "where updates come from" row plus the privacy footnote. +fn update_source(pal: Palette) -> AnyElement { + v_flex() + .w_full() + .gap_3() + .child( + h_flex() + .w_full() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(tr!("Update source")), + ) + .child( + div() + .text_xs() + .text_color(pal.text_muted) + .child("github.com/AprilNEA/OpenLogi/releases"), + ), + ) + .child( + Button::new("update-changelog") + .ghost() + .icon(IconName::ExternalLink) + .label(tr!("View changelog")) + .on_click(|_, _, cx| cx.open_url(RELEASES_URL)), + ), + ) + .child( + div() + .text_xs() + .text_color(pal.text_muted) + .child(tr!( + "No background updater — OpenLogi only connects when you turn on automatic checks or click Check for Updates." + )), + ) + .into_any_element() +} diff --git a/crates/openlogi-gui/themes/openlogi.json b/crates/openlogi-gui/themes/openlogi.json new file mode 100644 index 00000000..e62fc20b --- /dev/null +++ b/crates/openlogi-gui/themes/openlogi.json @@ -0,0 +1,211 @@ +{ + "$schema": "https://github.com/longbridge/gpui-component/raw/refs/heads/main/.theme-schema.json", + "name": "OpenLogi", + "author": "OpenLogi", + "url": "https://github.com/AprilNEA/OpenLogi", + "themes": [ + { + "is_default": true, + "name": "OpenLogi Light", + "mode": "light", + "colors": { + "accent.background": "neutral-100", + "accent.foreground": "neutral-900", + "accordion.background": "white", + "background": "#f4f4f6", + "border": "#d9d9e0", + "group_box.background": "#ffffff", + "group_box.foreground": "#171717", + "caret": "#0a0a0a", + "chart_1": "#93c5fd", + "chart_2": "#3b82f6", + "chart_3": "#2563eb", + "chart_4": "#1d4ed8", + "chart_5": "#1e40af", + "chart_bullish": "green-600", + "chart_bearish": "red-600", + "danger.background": "#ef4444", + "danger.foreground": "neutral-50", + "description_list_label.foreground": "#171717", + "drag_border": "#3b82f6", + "drop_target.background": "#3b82f640", + "foreground": "#1a1a1d", + "info.background": "cyan-500", + "info.foreground": "neutral-50", + "input.border": "neutral-200", + "link.foreground": "#0a0a0a", + "link.active.foreground": "#0a0a0a", + "link.hover.foreground": "#404040", + "list.background": "white", + "list.active.background": "#bfdbfe33", + "list.active.border": "#60a5fa", + "list.even.background": "#fafafa", + "list.head.background": "#fafafa", + "list.hover.background": "#f5f5f5", + "muted.background": "neutral-100", + "muted.foreground": "#6b6b73", + "popover.background": "white", + "popover.foreground": "neutral-950", + "primary.background": "#3b82f6", + "primary.active.background": "#2563eb", + "primary.foreground": "#ffffff", + "primary.hover.background": "#2f74e0", + "progress_bar.background": "#171717", + "ring": "#3b82f6", + "scrollbar.background": "#fafafa00", + "scrollbar.thumb.background": "#a3a3a3e6", + "scrollbar.thumb.hover.background": "#a3a3a3", + "secondary.background": "#ffffff", + "secondary.active.background": "#e1e1e8", + "secondary.foreground": "neutral-900", + "secondary.hover.background": "#e9e9ee", + "selection.background": "#55a0fc", + "sidebar.background": "#fafafa", + "sidebar.accent.background": "#e5e5e5", + "sidebar.accent.foreground": "#171717", + "sidebar.border": "#e5e5e5", + "sidebar.foreground": "#171717", + "sidebar.primary.background": "#171717", + "sidebar.primary.foreground": "#fafafa", + "skeleton.background": "#f5f5f5", + "slider.bar.background": "#171717", + "slider.thumb.background": "white", + "success.background": "#22c55e", + "success.foreground": "neutral-50", + "switch.background": "#d4d4d4", + "tab.background": "#00000000", + "tab.active.background": "white", + "tab.active.foreground": "#171717", + "tab_bar.background": "#f5f5f5", + "tab_bar.segmented.background": "#f5f5f5", + "tab.foreground": "#404040", + "table.background": "white", + "table.active.background": "#bfdbfe33", + "table.active.border": "#60a5fa", + "table.even.background": "#fafafa", + "table.head.background": "#fafafa", + "table.head.foreground": "#737373", + "table.hover.background": "#f5f5f5", + "table.row.border": "#e5e5e5b3", + "tiles.background": "#fafafa", + "title_bar.background": "#F8F8F8", + "title_bar.border": "#e5e5e5", + "warning.background": "#eab308", + "warning.foreground": "neutral-50", + "overlay": "#0000000d", + "window.border": "#e5e5e5", + "base.red": "red-600", + "base.red.light": "red-400", + "base.green": "green-600", + "base.green.light": "green-400", + "base.blue": "blue-600", + "base.blue.light": "blue-400", + "base.yellow": "yellow-600", + "base.yellow.light": "yellow-400", + "base.magenta": "purple-600", + "base.magenta.light": "purple-400", + "base.cyan": "cyan-600", + "base.cyan.light": "cyan-400" + } + }, + { + "is_default": true, + "name": "OpenLogi Dark", + "mode": "dark", + "colors": { + "accent.background": "neutral-800", + "accent.foreground": "neutral-50", + "accordion.background": "#0a0a0a", + "background": "#1a1a1d", + "border": "#2f2f36", + "group_box.background": "#222227", + "group_box.foreground": "neutral-50", + "caret": "#fafafa", + "chart_1": "#93c5fd", + "chart_2": "#3b82f6", + "chart_3": "#2563eb", + "chart_4": "#1d4ed8", + "chart_5": "#1e40af", + "chart_bullish": "green-600", + "chart_bearish": "red-600", + "danger.background": "#ef4444", + "danger.foreground": "red-600", + "description_list_label.background": "#171717", + "description_list_label.foreground": "#f5f5f5", + "drag_border": "#3b82f6", + "drop_target.background": "#3b82f619", + "foreground": "#e8e8ec", + "info.background": "cyan-400", + "info.foreground": "cyan-600", + "input.border": "#2f2f2f", + "link.foreground": "#fafafa", + "link.active.foreground": "#d4d4d4", + "link.hover.foreground": "#ffffff", + "list.background": "#0a0a0a", + "list.active.background": "#1e40af33", + "list.active.border": "#1d4ed8", + "list.even.background": "#17171766", + "list.head.background": "#17171766", + "muted.background": "neutral-800", + "muted.foreground": "#8a8a93", + "popover.background": "neutral-950", + "popover.foreground": "neutral-50", + "primary.background": "#3b82f6", + "primary.active.background": "#2563eb", + "primary.foreground": "#ffffff", + "primary.hover.background": "#5a93f8", + "progress_bar.background": "#f5f5f5", + "ring": "#3b82f6", + "scrollbar.background": "#17171700", + "scrollbar.thumb.background": "#525252e6", + "scrollbar.thumb.hover.background": "#525252", + "secondary.background": "#222227", + "secondary.active.background": "#34343c", + "secondary.foreground": "neutral-50", + "secondary.hover.background": "#2c2c33", + "selection.background": "#1d4ed8", + "sidebar.background": "#0a0a0a", + "sidebar.accent.background": "#262626", + "sidebar.accent.foreground": "#f5f5f5", + "sidebar.border": "#262626", + "sidebar.foreground": "#f5f5f5", + "sidebar.primary.background": "#f5f5f5", + "sidebar.primary.foreground": "#0a0a0a", + "skeleton.background": "#171717", + "slider.bar.background": "#fafafa", + "slider.thumb.background": "#0a0a0a", + "success.background": "#22c55e", + "success.foreground": "green-600", + "switch.background": "#404040", + "tab.background": "#00000000", + "tab.active.background": "#0a0a0a", + "tab.active.foreground": "#fafafa", + "tab_bar.background": "#171717", + "tab_bar.segmented.background": "#171717", + "tab.foreground": "#d4d4d4", + "table.background": "#0a0a0a", + "table.head.foreground": "#525252", + "table.row.border": "#262626b3", + "tiles.background": "#171717", + "title_bar.background": "#171717", + "title_bar.border": "#262626", + "warning.background": "#eab308", + "warning.foreground": "yellow-600", + "overlay": "#00000033", + "window.border": "#262626", + "base.red": "red-400", + "base.red.light": "red-300", + "base.green": "green-400", + "base.green.light": "green-300", + "base.blue": "blue-400", + "base.blue.light": "blue-300", + "base.yellow": "yellow-400", + "base.yellow.light": "yellow-300", + "base.magenta": "purple-400", + "base.magenta.light": "purple-300", + "base.cyan": "cyan-400", + "base.cyan.light": "cyan-300" + } + } + ] +}