From 1c8615335d7c75d0e2dedb81b0c738ce6d3e202e Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 09:00:59 +0800 Subject: [PATCH 1/4] refactor(gui): converge the colour layer and back the window with a macOS material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related pieces of the visual system, restored onto master from the mock-agent working tree. Colour tokens: `surface_hover` served three unrelated jobs (hover, armed resting state, recessed well), so hover states had drifted into four dialects. Replace it with one neutral interaction wash at two strengths — `wash` / `wash_strong`, alpha tints of the foreground so they composite correctly over any surface instead of matching exactly one. Add `text_ghost` for decorative marks and `border_strong` for an emphasised edge; both had call sites reaching for `text_muted`, a text weight, in their place. `WashStyle::hover_wash` puts the hover decision in one place. macOS material: the main window opts into `WindowBackgroundAppearance:: Blurred`, and `platform::os::configure_window_material` retargets the `NSVisualEffectView` gpui installs beneath its Metal layer at `WindowBackground`. gpui's `BlurredView` strips the material's desktop tinting and saturation, so the colour comes from the GPUI side instead: `Palette::backdrop` is the theme background at `BACKDROP_ALPHA`, a single translucent layer with everything above it opaque. Control affordances: ~20 hand-painted clickable surfaces had no press feedback, no focus ring and no tab stop — reachable by mouse only. `ControlStyle::control` adds a native cursor, a tab stop and a focus ring drawn as an outer shadow (a border would resize the element on focus and reflow tight rows). Activation needs no wiring: gpui already maps enter / space to a focused element's click listeners, and keeps the focus handle in element state under the element's id. Fixes en route: leader lines were a hardcoded grey authored for the dark theme and invisible-ish on light; the mouse and keyboard key chips hovered *darker* than their resting state; a delete affordance hovered to `gpui::white()`, vanishing on a light theme. --- Cargo.lock | 1 + crates/openlogi-gui/Cargo.toml | 7 +- crates/openlogi-gui/src/app.rs | 13 +- crates/openlogi-gui/src/app/home.rs | 7 +- crates/openlogi-gui/src/app/status.rs | 5 +- crates/openlogi-gui/src/app/widgets.rs | 6 +- .../src/components/action_ring_panel.rs | 6 +- .../components/action_ring_panel/editor.rs | 4 +- .../src/components/camera_controls.rs | 14 +- .../src/components/camera_preview.rs | 5 +- .../openlogi-gui/src/components/carousel.rs | 75 +++++---- .../openlogi-gui/src/components/dpi_panel.rs | 4 +- .../src/components/light_panel.rs | 21 ++- .../src/components/light_visual.rs | 2 +- .../src/components/lighting_panel.rs | 9 +- .../src/keyboard_model/function_row.rs | 33 +++- crates/openlogi-gui/src/main.rs | 26 ++- .../src/mouse_model/leader_lines.rs | 11 +- crates/openlogi-gui/src/mouse_model/picker.rs | 6 +- crates/openlogi-gui/src/mouse_model/view.rs | 28 +++- crates/openlogi-gui/src/platform/AGENTS.md | 11 +- crates/openlogi-gui/src/platform/os.rs | 71 +++++++- crates/openlogi-gui/src/theme.rs | 158 +++++++++++++++++- crates/openlogi-gui/src/windows/add_device.rs | 7 +- .../src/windows/settings/appearance.rs | 15 +- .../src/windows/settings/assets.rs | 7 +- .../src/windows/settings/permissions.rs | 7 +- 27 files changed, 431 insertions(+), 128 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00f65a41..10bbbd80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5185,6 +5185,7 @@ dependencies = [ "openlogi-core", "openlogi-hid", "openlogi-hook", + "raw-window-handle", "rust-i18n", "serde", "serde_json", diff --git a/crates/openlogi-gui/Cargo.toml b/crates/openlogi-gui/Cargo.toml index b05f4f50..9dc11139 100644 --- a/crates/openlogi-gui/Cargo.toml +++ b/crates/openlogi-gui/Cargo.toml @@ -68,13 +68,18 @@ embed-resource = "3.0.9" # (its bounds are display-relative). C FFI, so core-graphics per platform/AGENTS.md. core-graphics = { workspace = true } objc2 = { workspace = true } -objc2-app-kit = { workspace = true, features = ["NSStatusBar", "NSStatusItem", "NSStatusBarButton", "NSButton", "NSControl", "NSResponder", "NSView", "NSMenu", "NSMenuItem", "NSImage", "NSApplication", "NSAppearance", "NSWindow", "NSEvent", "block2"] } +objc2-app-kit = { workspace = true, features = ["NSStatusBar", "NSStatusItem", "NSStatusBarButton", "NSButton", "NSControl", "NSResponder", "NSView", "NSMenu", "NSMenuItem", "NSImage", "NSApplication", "NSAppearance", "NSVisualEffectView", "NSWindow", "NSEvent", "block2"] } objc2-foundation = { workspace = true, features = ["NSString", "NSProcessInfo"] } # Input-Monitoring status (`IOHIDCheckAccess`) with typed request/access enums — # see platform/permissions.rs. objc2-io-kit = { workspace = true, features = ["std", "hidsystem"] } # NSEvent global-monitor handlers are ObjC blocks (overlay click-away dismissal). block2 = { workspace = true } +# Reaching gpui's `NSView` to retarget the window's visual-effect material +# (platform::os::configure_window_material). The exact version already in +# Cargo.lock as gpui's own dependency, so the resolve adds an edge, not a crate, +# and the pinned gpui rev cannot move. +raw-window-handle = "0.6" # unsafe_code stays denied; the status-item/tray modules opt in locally with # #[expect(unsafe_code)] for the few objc2 calls (init-with-action, set-target, diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index cbc85aeb..cbc82c54 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -26,7 +26,7 @@ use crate::components::smartshift_panel::SmartShiftPanel; use crate::keyboard_model::function_row::FunctionRowView; use crate::mouse_model::view::MouseModelView; use crate::state::{AgentLink, AppState, DeviceRecord}; -use crate::theme::{self, Palette, Typography as _}; +use crate::theme::{self, ControlStyle as _, Palette, Typography as _}; mod detail; mod home; @@ -304,7 +304,9 @@ impl AppView { fn accessibility_gate(pal: Palette, cx: &mut Context) -> AnyElement { v_flex() .size_full() - .bg(pal.bg) + // No fill: like the connecting / unreachable frames, this sits on + // the backdrop the root already establishes. Repainting it here + // would stack a second translucent layer on macOS. .text_color(pal.text_primary) .items_center() .justify_center() @@ -359,7 +361,7 @@ impl AppView { .id("skip-accessibility") .text_caption() .text_color(pal.text_muted) - .cursor_pointer() + .control(pal).press_wash(pal) .hover(|s| s.text_color(pal.text_primary)) .child(tr!("Not now (use DPI and other features only)")) .on_click(cx.listener(|this, _, _, cx| { @@ -415,7 +417,7 @@ impl Render for AppView { // first frame on, not only once the full UI is up. let root = v_flex() .size_full() - .bg(pal.bg) + .bg(pal.backdrop) .text_color(pal.text_primary) .track_focus(&self.focus_handle) .on_action(|_: &CloseWindow, window, _| window.remove_window()) @@ -559,6 +561,9 @@ impl Render for AppView { ) }; + // No second fill between here and the root: the backdrop is one + // translucent layer on macOS, and a nested surface repainting it would + // stack alpha and show as a brighter band behind the body. root.child(header_el) .child(content_el) .child(status::footer(pal, granted)) diff --git a/crates/openlogi-gui/src/app/home.rs b/crates/openlogi-gui/src/app/home.rs index 80ab1797..7f0af556 100644 --- a/crates/openlogi-gui/src/app/home.rs +++ b/crates/openlogi-gui/src/app/home.rs @@ -27,7 +27,9 @@ use crate::asset::GlowGeometry; use crate::components::carousel::Carousel; use crate::components::light_visual; use crate::state::{AppState, DeviceRecord}; -use crate::theme::{self, HEADER_H, Palette, SelectableStyle as _, Typography as _}; +use crate::theme::{ + self, ControlStyle as _, HEADER_H, Palette, SelectableStyle as _, Typography as _, +}; /// Home (gallery) top bar: the "Devices" title, a Settings gear, and the /// Add-Device button — the entry points the old carousel header used to carry. @@ -121,7 +123,8 @@ pub(super) fn device_gallery(cx: &mut Context) -> impl IntoElement { .aria_label(record.display_name.clone()) .aria_description(device_accessibility_description(&record)) .aria_selected(focused) - .cursor_pointer() + .control(pal) + .press_wash(pal) .hover(move |s| s.border_color(rgb(theme::ACCENT_BLUE)).shadow_sm()) .on_click(move |_, _, cx| { view.update(cx, |this, cx| this.open_device(key.clone(), cx)); diff --git a/crates/openlogi-gui/src/app/status.rs b/crates/openlogi-gui/src/app/status.rs index ce3521ca..ec5d8dce 100644 --- a/crates/openlogi-gui/src/app/status.rs +++ b/crates/openlogi-gui/src/app/status.rs @@ -11,7 +11,7 @@ use gpui_component::{ v_flex, }; -use crate::theme::{self, FOOTER_H, Palette, Typography as _}; +use crate::theme::{self, ControlStyle as _, FOOTER_H, Palette, Typography as _}; /// Centered spinner over a muted one-line caption — the quiet "still working" /// body shared by the pre-connection frame and the scanning state, so the two @@ -170,7 +170,8 @@ fn accessibility_status(pal: Palette, granted: bool) -> AnyElement { .items_center() .text_caption() .text_color(pal.text_primary) - .cursor_pointer() + .control(pal) + .press_wash(pal) .child( div() .size_1p5() diff --git a/crates/openlogi-gui/src/app/widgets.rs b/crates/openlogi-gui/src/app/widgets.rs index 7a7d9176..d3c7d99a 100644 --- a/crates/openlogi-gui/src/app/widgets.rs +++ b/crates/openlogi-gui/src/app/widgets.rs @@ -164,11 +164,7 @@ pub(super) fn battery_summary(battery: &BatteryInfo, pal: Palette) -> impl IntoE }), ) .child({ - let track = div() - .h(px(6.)) - .w_full() - .rounded_full() - .bg(pal.surface_hover); + let track = div().h(px(6.)).w_full().rounded_full().bg(pal.wash_strong); // Charging with no reliable %: leave the track empty rather than // drawing the 1%-wide red critical sliver that percentage==0 yields. if battery_charging_no_reading(battery) { diff --git a/crates/openlogi-gui/src/components/action_ring_panel.rs b/crates/openlogi-gui/src/components/action_ring_panel.rs index a735b463..955b9e32 100644 --- a/crates/openlogi-gui/src/components/action_ring_panel.rs +++ b/crates/openlogi-gui/src/components/action_ring_panel.rs @@ -221,7 +221,7 @@ fn ring_preview( .items_center() .justify_center() .rounded_full() - .bg(pal.surface_hover) + .bg(pal.wash_strong) .text_color(pal.text_muted) .child("×"), ) @@ -279,7 +279,7 @@ fn slot_button( .bg(if selected { theme::accent_tint() } else { - pal.surface_hover + pal.wash }) .text_color(if selected { pal.text_primary @@ -304,7 +304,7 @@ fn slot_button( button.bg(if selected { theme::accent_tint_hover() } else { - pal.surface_hover + pal.wash_strong }) }) .on_click(move |_, _, cx| { diff --git a/crates/openlogi-gui/src/components/action_ring_panel/editor.rs b/crates/openlogi-gui/src/components/action_ring_panel/editor.rs index 69f0453a..e8eab38b 100644 --- a/crates/openlogi-gui/src/components/action_ring_panel/editor.rs +++ b/crates/openlogi-gui/src/components/action_ring_panel/editor.rs @@ -21,7 +21,7 @@ use openlogi_core::binding::{ use crate::action_icons::action_icon_path; use crate::action_ring_icons::ring_icon_path; use crate::state::AppState; -use crate::theme::{self, Palette, SelectableStyle as _, Typography as _}; +use crate::theme::{self, Palette, SelectableStyle as _, Typography as _, WashStyle as _}; pub(super) fn action_library( slot: ActionRingSlot, @@ -265,7 +265,7 @@ fn action_rows(slot: ActionRingSlot, current: Option<&Action>, pal: Palette) -> .text_color(rgb(theme::ACCENT_BLUE)), ) }) - .hover(move |row| row.bg(pal.surface_hover)) + .hover_wash(pal) .on_click(move |_, _, cx| { commit_action(slot, action_to_commit.clone(), cx); }) diff --git a/crates/openlogi-gui/src/components/camera_controls.rs b/crates/openlogi-gui/src/components/camera_controls.rs index e480b2ea..1983e5b3 100644 --- a/crates/openlogi-gui/src/components/camera_controls.rs +++ b/crates/openlogi-gui/src/components/camera_controls.rs @@ -35,7 +35,7 @@ use openlogi_core::config::CameraControls; use tracing::debug; use crate::state::AppState; -use crate::theme::{self, ACCENT_BLUE, Palette}; +use crate::theme::{self, ACCENT_BLUE, Palette, WashStyle as _}; /// Built-in profiles: `values` are fractions of each control's own range, so /// they scale to whatever the camera reports. Auto modes all engage — the @@ -764,7 +764,7 @@ fn profiles_row(key: &str, pal: Palette, cx: &mut Context) .border_color(pal.border) .text_xs() .text_color(pal.text_muted) - .hover(|s| s.bg(pal.surface_hover)) + .hover_wash(pal) .child(format!("+ {}", tr!("New"))) .on_click(cx.listener(|panel, _: &ClickEvent, _window, cx| { panel.save_profile(cx); @@ -795,7 +795,7 @@ fn profile_chip( pal.text_muted }) .when(active, |s| s.bg(pal.surface)) - .hover(move |s| s.bg(pal.surface_hover)) + .hover_wash(pal) .child(label) .on_click(on_click) .into_any_element() @@ -830,7 +830,7 @@ fn custom_profile_chip( pal.text_muted }) .when(active, |s| s.bg(pal.surface)) - .hover(move |s| s.bg(pal.surface_hover)) + .hover_wash(pal) .child(SharedString::from(name)) .on_click(cx.listener(move |panel, _: &ClickEvent, window, cx| { panel.apply_profile(&apply_name, window, cx); @@ -841,7 +841,7 @@ fn custom_profile_chip( .px_0p5() .rounded_full() .text_color(pal.text_muted) - .hover(|s| s.text_color(gpui::white())) + .hover(|s| s.text_color(pal.text_primary)) .child("×") .on_click(cx.listener(move |panel, _: &ClickEvent, _window, cx| { cx.stop_propagation(); @@ -939,7 +939,7 @@ fn control_row( .border_color(if on { accent.into() } else { pal.border }) .text_xs() .text_color(if on { accent.into() } else { pal.text_muted }) - .hover(|s| s.bg(pal.surface_hover)) + .hover_wash(pal) .child(tr!("Auto")) .on_click(cx.listener(move |panel, _: &ClickEvent, _window, cx| { panel.toggle_auto(auto_ix, cx); @@ -964,7 +964,7 @@ fn reset_button(pal: Palette, cx: &mut Context) -> AnyEleme .border_1() .border_color(pal.border) .bg(pal.surface) - .hover(|s| s.bg(pal.surface_hover)) + .hover_wash(pal) .text_xs() .text_color(pal.text_muted) .child(tr!("Reset to defaults")) diff --git a/crates/openlogi-gui/src/components/camera_preview.rs b/crates/openlogi-gui/src/components/camera_preview.rs index 4b345f1b..8cdf57fd 100644 --- a/crates/openlogi-gui/src/components/camera_preview.rs +++ b/crates/openlogi-gui/src/components/camera_preview.rs @@ -31,7 +31,7 @@ use gpui_component::v_flex; use image::{Frame as ImageFrame, RgbaImage}; use openlogi_camera::{CameraAuthorization, CameraStream, Frame}; -use crate::theme::{self, Palette}; +use crate::theme::{self, ControlStyle as _, Palette}; const PREVIEW_W: f32 = 480.; const PREVIEW_H: f32 = 270.; // 16:9 @@ -175,7 +175,8 @@ impl Render for CameraPreview { .id("camera-request-access") .text_sm() .text_color(pal.text_muted) - .cursor_pointer() + .control(pal) + .press_wash(pal) .hover(|s| s.text_color(pal.text_primary)) .child(tr!("Click to enable camera access.")) .on_click(|_, _, cx| crate::platform::permissions::request_camera_access(cx)) diff --git a/crates/openlogi-gui/src/components/carousel.rs b/crates/openlogi-gui/src/components/carousel.rs index 25d94584..6ffc0bed 100644 --- a/crates/openlogi-gui/src/components/carousel.rs +++ b/crates/openlogi-gui/src/components/carousel.rs @@ -35,6 +35,8 @@ use gpui_component::{ h_flex, v_flex, }; +use crate::theme::{ControlStyle as _, Palette}; + type SelectHandler = Rc; type ItemRenderer = Rc AnyElement + 'static>; @@ -206,7 +208,7 @@ impl Carousel { let selected = selected.min(len - 1); let multi = len > 1; let accent = accent.unwrap_or(cx.theme().primary); - let dot_idle = cx.theme().border; + let dot_colors = DotColors::resolve(accent, cx); let scroll_state = window.use_keyed_state(SharedString::from(format!("{id}-scroll")), cx, |_, _| { @@ -291,9 +293,7 @@ impl Carousel { .justify_center() .gap_1p5() .children( - (0..len).map(|i| { - dot(i, i == selected, accent, dot_idle, on_select.clone()) - }), + (0..len).map(|i| dot(i, i == selected, dot_colors, on_select.clone())), ), ) }) @@ -324,7 +324,7 @@ impl Carousel { let selected = selected.min(len - 1); let multi = len > 1; let accent = accent.unwrap_or(cx.theme().primary); - let dot_idle = cx.theme().border; + let dot_colors = DotColors::resolve(accent, cx); let has_prev = selected > 0; let has_next = selected + 1 < len; @@ -379,6 +379,8 @@ impl Carousel { }, ); + // Both peeks differ only in their element and index. + let peek = |el, index| side_slot(el, index, side_frac, dot_colors.pal, on_select.clone()); let stage = h_flex() .id("carousel-stage") .w_full() @@ -389,22 +391,10 @@ impl Carousel { .gap(gap) .overflow_hidden() .when(multi, |this| { - this.child(side_slot( - prev_el, - selected.saturating_sub(1), - side_frac, - on_select.clone(), - )) + this.child(peek(prev_el, selected.saturating_sub(1))) }) .child(focused_slot) - .when(multi, |this| { - this.child(side_slot( - next_el, - selected + 1, - side_frac, - on_select.clone(), - )) - }); + .when(multi, |this| this.child(peek(next_el, selected + 1))); v_flex() .size_full() @@ -416,8 +406,7 @@ impl Carousel { selected, arrows, indicators, - accent, - dot_idle, + dot_colors, on_select.as_ref(), )) }) @@ -425,14 +414,36 @@ impl Carousel { } } +/// Everything a page-indicator dot paints itself with, bundled so it travels +/// as one argument from the render pass down to [`dot`]. +#[derive(Clone, Copy)] +struct DotColors { + /// Fill of the dot for the selected page. + accent: Hsla, + /// Fill of every other dot. + idle: Hsla, + /// Also the carousel's own control palette — the peeking slots read it too, + /// so resolving it once here keeps both render passes to one lookup. + pal: Palette, +} + +impl DotColors { + fn resolve(accent: Hsla, cx: &App) -> Self { + Self { + accent, + idle: cx.theme().border, + pal: crate::theme::palette(cx), + } + } +} + /// The bottom control row: prev/next arrows flanking the page-indicator dots. fn controls( len: usize, selected: usize, arrows: bool, indicators: bool, - accent: Hsla, - idle: Hsla, + colors: DotColors, on_select: Option<&SelectHandler>, ) -> impl IntoElement { h_flex() @@ -451,9 +462,12 @@ fn controls( )) }) .when(indicators, |t| { - t.child(h_flex().items_center().gap_1p5().children( - (0..len).map(|i| dot(i, i == selected, accent, idle, on_select.cloned())), - )) + t.child( + h_flex() + .items_center() + .gap_1p5() + .children((0..len).map(|i| dot(i, i == selected, colors, on_select.cloned()))), + ) }) .when(arrows, |t| { t.child(arrow( @@ -473,6 +487,7 @@ fn side_slot( el: Option, index: usize, frac: f32, + pal: Palette, on_select: Option, ) -> AnyElement { let base = div() @@ -486,7 +501,7 @@ fn side_slot( Some(el) => base .id(("carousel-peek", index)) .opacity(0.6) - .cursor_pointer() + .control(pal) .hover(|s| s.opacity(0.85)) .when_some(on_select, |this, handler| { this.on_click(move |_, window, cx| handler(&index, window, cx)) @@ -518,10 +533,10 @@ fn arrow( fn dot( index: usize, active: bool, - accent: Hsla, - idle: Hsla, + colors: DotColors, on_select: Option, ) -> impl IntoElement { + let DotColors { accent, idle, pal } = colors; let size = if active { px(8.) } else { px(6.) }; div() .id(("carousel-dot", index)) @@ -529,7 +544,7 @@ fn dot( .h(size) .rounded_full() .bg(if active { accent } else { idle }) - .cursor_pointer() + .control(pal) .when_some(on_select, |this, handler| { this.on_click(move |_, window, cx| handler(&index, window, cx)) }) diff --git a/crates/openlogi-gui/src/components/dpi_panel.rs b/crates/openlogi-gui/src/components/dpi_panel.rs index 77dc3a83..ecd3ac56 100644 --- a/crates/openlogi-gui/src/components/dpi_panel.rs +++ b/crates/openlogi-gui/src/components/dpi_panel.rs @@ -21,7 +21,7 @@ use tracing::debug; use crate::components::device_read::issue_device_read; use crate::components::status::{retry_line, status_line}; use crate::state::{AppState, DpiStatus}; -use crate::theme::{self, Palette, SelectableStyle, Typography as _}; +use crate::theme::{self, Palette, SelectableStyle, Typography as _, WashStyle as _}; pub struct DpiPanel { slider_state: Option>, @@ -366,7 +366,7 @@ fn preset_chip(idx: usize, value: u32, active: bool, presets: &[u32], pal: Palet .selected_border(active, pal) .bg(pal.surface) .selected_fill(active) - .hover(|s| s.bg(pal.surface_hover)) + .hover_wash(pal) .child( Button::new(("dpi-preset-apply", idx)) .compact() diff --git a/crates/openlogi-gui/src/components/light_panel.rs b/crates/openlogi-gui/src/components/light_panel.rs index 8d2f6857..335678a2 100644 --- a/crates/openlogi-gui/src/components/light_panel.rs +++ b/crates/openlogi-gui/src/components/light_panel.rs @@ -1,7 +1,10 @@ //! Capability-driven controls for standalone lights. use crate::state::{AppState, LightCommandStatus}; -use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle as _, Typography as _}; +use crate::theme::{ + self, ACCENT_BLUE, ControlStyle as _, Palette, SelectableStyle as _, Typography as _, + WashStyle as _, +}; use gpui::{ AppContext as _, BorrowAppContext as _, BoxShadow, Context, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, Render, StatefulInteractiveElement as _, Styled, Subscription, @@ -266,7 +269,7 @@ fn light_emblem(enabled: bool, pal: Palette) -> impl IntoElement { let halo = if enabled { hsla(0.105, 0.9, 0.66, 0.22) } else { - pal.surface_hover + pal.wash_strong }; let icon_color: Hsla = if enabled { hsla(0.105, 0.9, 0.66, 1.) @@ -323,7 +326,7 @@ fn camera_automation(current: LightSettings, pal: Palette) -> impl IntoElement { .bg(if current.auto_camera { hsla(0.105, 0.9, 0.66, 0.08) } else { - pal.surface_hover + pal.wash }) .p_3() .child( @@ -359,8 +362,9 @@ fn camera_automation(current: LightSettings, pal: Palette) -> impl IntoElement { } else { pal.text_muted }) - .cursor_pointer() - .hover(|style| style.bg(pal.surface_hover)) + .control(pal) + .press_wash(pal) + .hover_wash(pal) .child(if current.auto_camera { tr!("On") } else { @@ -406,7 +410,7 @@ fn control_well( .rounded(pal.control_radius) .border_1() .border_color(pal.border) - .bg(pal.surface_hover) + .bg(pal.wash) .p_3() .child( h_flex() @@ -448,8 +452,9 @@ fn toggle(effective_enabled: bool, pal: Palette) -> impl IntoElement { .selected_fill(on) .text_caption() .text_color(if on { pal.text_primary } else { pal.text_muted }) - .cursor_pointer() - .hover(|style| style.bg(pal.surface_hover)) + .control(pal) + .press_wash(pal) + .hover_wash(pal) .child(Icon::new(icon).size_3()) .child(if on { tr!("On") } else { tr!("Off") }) .on_click(move |_event, _window, cx| { diff --git a/crates/openlogi-gui/src/components/light_visual.rs b/crates/openlogi-gui/src/components/light_visual.rs index cb48ae37..2a61d099 100644 --- a/crates/openlogi-gui/src/components/light_visual.rs +++ b/crates/openlogi-gui/src/components/light_visual.rs @@ -102,7 +102,7 @@ fn generated_visual( .bg(if powered { glow.opacity(0.08 + brightness * 0.18) } else { - pal.surface_hover + pal.wash_strong }); let halo = if powered { halo.shadow(vec![BoxShadow { diff --git a/crates/openlogi-gui/src/components/lighting_panel.rs b/crates/openlogi-gui/src/components/lighting_panel.rs index 277f98ee..976a41e0 100644 --- a/crates/openlogi-gui/src/components/lighting_panel.rs +++ b/crates/openlogi-gui/src/components/lighting_panel.rs @@ -18,7 +18,7 @@ use openlogi_core::color::Rgb; use openlogi_core::config::Lighting; use crate::state::AppState; -use crate::theme::{self, Palette, SelectableStyle, Typography as _}; +use crate::theme::{self, ControlStyle as _, Palette, SelectableStyle, Typography as _}; const SWATCH: f32 = 28.; @@ -159,7 +159,9 @@ fn swatch(idx: usize, color: Rgb, current: &Lighting, pal: Palette) -> AnyElemen pal.border }) .bg(rgb(color.packed())) - .cursor_pointer() + // No press wash: the swatch *is* the colour, so a neutral fill over it + // would read as the colour changing. + .control(pal) .on_click(move |_event, _window, cx| { cx.update_global::(|state, _| { let mut next = state.lighting(); @@ -184,7 +186,8 @@ fn toggle(current: &Lighting, pal: Palette) -> AnyElement { .selected_fill(on) .text_caption() .text_color(if on { pal.text_primary } else { pal.text_muted }) - .cursor_pointer() + .control(pal) + .press_wash(pal) .child(if on { tr!("On") } else { tr!("Off") }) .on_click(|_event, _window, cx| { cx.update_global::(|state, _| { diff --git a/crates/openlogi-gui/src/keyboard_model/function_row.rs b/crates/openlogi-gui/src/keyboard_model/function_row.rs index 07c95336..98436b72 100644 --- a/crates/openlogi-gui/src/keyboard_model/function_row.rs +++ b/crates/openlogi-gui/src/keyboard_model/function_row.rs @@ -24,7 +24,7 @@ use std::sync::Arc; use gpui::{ AnyElement, AppContext as _, BorrowAppContext as _, Bounds, Context, Entity, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, PathBuilder, Render, - StatefulInteractiveElement as _, Styled, Subscription, Window, canvas, div, hsla, point, + StatefulInteractiveElement as _, Styled, Subscription, Window, canvas, div, point, prelude::FluentBuilder as _, px, rgb, svg, }; use gpui_component::{h_flex, input::InputState, v_flex}; @@ -43,7 +43,7 @@ use crate::mouse_model::picker::{ section_header, }; use crate::state::AppState; -use crate::theme::{self, ACCENT_BLUE, Palette}; +use crate::theme::{self, ACCENT_BLUE, ControlStyle as _, Palette}; use gpui::ease_in_out; use gpui::{Animation, AnimationExt, img}; @@ -529,9 +529,11 @@ fn key_callout( .bg(if highlighted { theme::accent_tint() } else { - pal.surface_hover + pal.wash }) - .cursor_pointer() + // No press wash: this chip carries the selection tint, which a neutral + // fill would momentarily erase. + .control(*pal) .hover(move |s| { s.bg(if highlighted { theme::accent_tint_hover() @@ -597,7 +599,7 @@ fn key_click_target( highlighted: bool, (img_w, img_h): (f32, f32), view: &Entity, - _pal: &Palette, + pal: &Palette, ) -> AnyElement { let idx = slot.idx; let x_frac = slot.x_frac; @@ -617,7 +619,8 @@ fn key_click_target( .flex() .items_center() .justify_center() - .cursor_pointer() + .control(*pal) + .press_wash(*pal) .when(highlighted, |el| { el.child( div() @@ -669,9 +672,20 @@ fn keyboard_leader_canvas( slots.iter().map(|s| (s.idx, s.x_frac, s.y_frac)).collect(); canvas( move |_bounds, _, _| (guides, selected, hovered), - move |bounds, payload, window, _app| { + // Resolved at paint time rather than captured, so an appearance flip + // repaints these lines with the new theme (see `leader_canvas`). + move |bounds, payload, window, app| { let (guides, selected, hovered) = payload; - paint_keyboard_leaders(bounds, guides, selected, hovered, (img_w, img_h), window); + let pal = theme::palette(app); + paint_keyboard_leaders( + bounds, + guides, + selected, + hovered, + (img_w, img_h), + pal, + window, + ); }, ) .absolute() @@ -686,6 +700,7 @@ fn paint_keyboard_leaders( selected: Option, hovered: Option, (img_w, img_h): (f32, f32), + pal: Palette, window: &mut Window, ) { let count = guides.len(); @@ -707,7 +722,7 @@ fn paint_keyboard_leaders( if highlighted { window.paint_path(path, rgb(ACCENT_BLUE)); } else { - window.paint_path(path, hsla(0., 0., 0.55, 0.35)); + window.paint_path(path, pal.text_ghost); } } } diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index 95f0c059..200bcab9 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -59,9 +59,10 @@ use std::time::Instant; use anyhow::Result; use gpui::{ - AppContext, BorrowAppContext as _, Bounds, Size, Styled, WindowBounds, WindowOptions, px, + AppContext, BorrowAppContext as _, Bounds, Size, Styled, WindowBackgroundAppearance, + WindowBounds, WindowOptions, px, }; -use gpui_component::{ActiveTheme, Root}; +use gpui_component::Root; use openlogi_core::brand::{APP_ID, DeeplinkCommand}; use openlogi_core::config::Config; use openlogi_core::device::{DeviceInventory, StandaloneDevice}; @@ -597,6 +598,17 @@ fn main_window_options(cx: &mut gpui::App) -> WindowOptions { // `TitleBar` (the compositor declines server-side decorations and gpui's // fallback is unpainted). macOS/Windows keep their native titlebar. titlebar: Some(windows::titlebar_options("OpenLogi")), + // macOS: back the window with a real `NSVisualEffectView`, which gpui + // installs for `Blurred`, so the window sits on a live blur instead of + // being an opaque rectangle. `platform::os::configure_window_material` + // picks its material once the window exists, and `Palette::backdrop` + // supplies the theme colour over it. Elsewhere the window stays an + // ordinary opaque surface. + window_background: if cfg!(target_os = "macos") { + WindowBackgroundAppearance::Blurred + } else { + WindowBackgroundAppearance::Opaque + }, ..WindowOptions::default() } } @@ -627,12 +639,18 @@ fn open_main_window(inventories: &[DeviceInventory], cx: &mut gpui::App) { }); view.update(cx, |v, _| v.set_appearance_obs(appearance_obs)); - cx.new(|cx| Root::new(view, window, cx).bg(cx.theme().background)) + cx.new(|cx| Root::new(view, window, cx).bg(theme::palette(cx).backdrop)) }); match opened { Ok(handle) => { - let _ = handle.update(cx, |_, window, _| window.activate_window()); + let _ = handle.update(cx, |_, window, _| { + // After the window exists: gpui creates the visual-effect view + // while applying `window_background`, so there is nothing to + // retarget until then. + platform::os::configure_window_material(window); + window.activate_window(); + }); cx.default_global::().main = Some(handle); cx.activate(true); } diff --git a/crates/openlogi-gui/src/mouse_model/leader_lines.rs b/crates/openlogi-gui/src/mouse_model/leader_lines.rs index c45f67a2..ad763537 100644 --- a/crates/openlogi-gui/src/mouse_model/leader_lines.rs +++ b/crates/openlogi-gui/src/mouse_model/leader_lines.rs @@ -4,10 +4,10 @@ //! stub → diagonal to the label anchor. The active hotspot's line is //! coloured blue and stroked thicker; everything else stays muted. -use gpui::{Bounds, PathBuilder, Pixels, Point, Window, hsla, point, px, rgb}; +use gpui::{Bounds, PathBuilder, Pixels, Point, Window, point, px, rgb}; use crate::data::mouse_buttons::{Hotspot, MouseControlId}; -use crate::theme::ACCENT_BLUE; +use crate::theme::{ACCENT_BLUE, Palette}; /// Length of the horizontal stub before turning toward the label. /// Kept small enough to fit inside the gap between mouse and card so @@ -59,6 +59,7 @@ pub fn paint( hotspots: &[Hotspot], labels: &[Label], highlighted: Option, + pal: Palette, window: &mut Window, ) { for label in labels { @@ -71,6 +72,7 @@ pub fn paint( *hotspot, *label, highlighted == Some(label.id), + pal, window, ); } @@ -82,6 +84,7 @@ fn paint_one( hotspot: Hotspot, label: Label, highlight: bool, + pal: Palette, window: &mut Window, ) { let Geometry { @@ -129,9 +132,7 @@ fn paint_one( if highlight { window.paint_path(built, rgb(ACCENT_BLUE)); } else { - // Muted gray — readable against the dark background without - // competing with the highlighted line. - window.paint_path(built, hsla(0., 0., 0.55, 0.35)); + window.paint_path(built, pal.text_ghost); } } } diff --git a/crates/openlogi-gui/src/mouse_model/picker.rs b/crates/openlogi-gui/src/mouse_model/picker.rs index 04c88454..7db11a62 100644 --- a/crates/openlogi-gui/src/mouse_model/picker.rs +++ b/crates/openlogi-gui/src/mouse_model/picker.rs @@ -37,7 +37,7 @@ use crate::data::mouse_buttons::{ use crate::mouse_model::thumbwheel::ThumbwheelPreset; use crate::mouse_model::view::MouseModelView; use crate::state::AppState; -use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle, Typography as _}; +use crate::theme::{self, ACCENT_BLUE, Palette, SelectableStyle, Typography as _, WashStyle as _}; /// Floor width for the [`action_picker`] popover. The action labels drive the /// actual width; this only stops the list from collapsing too narrow. Matches @@ -408,7 +408,7 @@ fn direction_cell( .rounded(pal.control_radius) .selected_border(active, pal) .selected_fill(active) - .hover(move |s| s.bg(pal.surface_hover)) + .hover_wash(pal) .child(div().text_caption().text_color(pal.text_muted).child(header)) .child( div() @@ -639,7 +639,7 @@ pub(crate) fn menu_row( s.bg(if selected { theme::accent_tint_hover() } else { - pal.surface_hover + pal.wash }) }) } diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs index 2c039f98..6634c549 100644 --- a/crates/openlogi-gui/src/mouse_model/view.rs +++ b/crates/openlogi-gui/src/mouse_model/view.rs @@ -25,7 +25,7 @@ use crate::mouse_model::picker::{ }; use crate::mouse_model::thumbwheel::ThumbwheelPreset; use crate::state::AppState; -use crate::theme::{self, ACCENT_BLUE, Palette, Typography as _}; +use crate::theme::{self, ACCENT_BLUE, ControlStyle as _, Palette, Typography as _}; const SIDE_W: f32 = 180.; const SIDE_GAP: f32 = 24.; @@ -258,7 +258,10 @@ fn leader_canvas( ) -> impl IntoElement { canvas( move |_bounds, _, _| (hotspots, labels, highlight), - move |bounds, payload, window, _app| { + // Resolved at paint time rather than captured: the canvas outlives an + // appearance flip, and a captured palette would keep painting the old + // theme's lines until something else forced a rebuild. + move |bounds, payload, window, app| { let (hotspots, labels, highlight) = payload; paint_leader_lines( bounds, @@ -270,6 +273,7 @@ fn leader_canvas( &hotspots, &labels, highlight, + theme::palette(app), window, ); }, @@ -533,12 +537,20 @@ impl RenderOnce for LabelTrigger { pal.border }) .bg(if highlighted { - pal.surface + theme::accent_tint() } else { - pal.surface_hover + pal.wash + }) + // No press wash: this chip carries the selection tint, which a + // neutral fill would momentarily erase. + .control(pal) + .hover(move |s| { + s.bg(if highlighted { + theme::accent_tint_hover() + } else { + pal.wash_strong + }) }) - .cursor_pointer() - .hover(move |s| s.bg(pal.surface)) // Button name — the caption (xs / muted), the same size as the // popover title and category headers it shares the binding flow with. .child( @@ -679,8 +691,8 @@ fn silhouette(w: f32, h: f32, pal: Palette) -> impl IntoElement { .h(px(h)) .rounded_3xl() .border_1() - .border_color(pal.text_muted) - .bg(pal.surface_hover) + .border_color(pal.text_ghost) + .bg(pal.wash) .child( div() .absolute() diff --git a/crates/openlogi-gui/src/platform/AGENTS.md b/crates/openlogi-gui/src/platform/AGENTS.md index 242b8a37..735ab201 100644 --- a/crates/openlogi-gui/src/platform/AGENTS.md +++ b/crates/openlogi-gui/src/platform/AGENTS.md @@ -5,8 +5,15 @@ on **`objc2`** (0.6 / framework crates 0.3): `Retained` smart pointers, typed AppKit objects, `define_class!` for subclasses. The whole workspace's ObjC-runtime FFI is exactly these files — keep them in sync: -- `status_item.rs` — safe `objc2` wrappers over `NSStatusItem` / `NSMenu` / `NSMenuItem`. -- `tray.rs` — the OpenLogi menu-bar semantics + the `OpenLogiMenuTarget` (`define_class!`). +- `os.rs` — `NSProcessInfo` version read, `NSApp.appearance` override, and + `configure_window_material` (retargets the `NSVisualEffectView` gpui installs under a + `Blurred` window; reaches gpui's `NSView` via `raw-window-handle`). +- `overlay.rs` — window policy for the Actions Ring overlay: `NSApplicationActivationPolicy`, + borderless/shadowless panels, and the `NSEvent` global monitor behind its click-away. +- `crates/openlogi-agent/src/status_item.rs` — safe `objc2` wrappers over `NSStatusItem` / + `NSMenu` / `NSMenuItem`. +- `crates/openlogi-agent/src/tray.rs` — the OpenLogi menu-bar semantics + the + `OpenLogiMenuTarget` (`define_class!`). - `permissions.rs` — `CBCentralManager.authorization` (`objc2` class lookup) + `IOHIDCheckAccess` (`objc2-io-kit`). - `crates/openlogi-hook/src/macos.rs` — CGEventTap (on `core-graphics`, see below), the diff --git a/crates/openlogi-gui/src/platform/os.rs b/crates/openlogi-gui/src/platform/os.rs index fdf75304..10ac58d2 100644 --- a/crates/openlogi-gui/src/platform/os.rs +++ b/crates/openlogi-gui/src/platform/os.rs @@ -1,5 +1,7 @@ -//! Best-effort host OS version string for the diagnostics report, plus syncing -//! the native window chrome (titlebar) to the in-app appearance preference. +//! Best-effort host OS version string for the diagnostics report, plus the +//! native window chrome: syncing the titlebar to the in-app appearance +//! preference, and pointing the main window's backdrop at a real macOS +//! material. use openlogi_core::config::Appearance; @@ -57,3 +59,68 @@ pub fn set_app_appearance(appearance: Appearance) { #[cfg(not(target_os = "macos"))] pub fn set_app_appearance(_appearance: Appearance) {} + +/// Retarget the main window's native backdrop at the macOS window-background +/// material. +/// +/// GPUI already installs an `NSVisualEffectView` beneath its Metal layer for a +/// window that asks for [`WindowBackgroundAppearance::Blurred`][blurred], but +/// it picks `NSVisualEffectMaterial::Selection` — a highlight tint, not a +/// window backdrop. We retarget the view GPUI owns rather than stacking one of +/// our own, which would composite two vibrancies and read as muddy. +/// +/// Note what this does *not* buy: GPUI's `BlurredView` overrides `updateLayer` +/// to nil the material's background colour, hide its `CAChameleonLayer` +/// (desktop tinting) and strip its saturation filter, so no material here +/// renders AppKit's full look. What survives is the blur, and the material +/// chooses its radius — `WindowBackground`'s is the one sized for a window +/// backdrop. The colour comes from the GPUI side instead, as a single +/// translucent fill: [`Palette::backdrop`](crate::theme::Palette::backdrop). +/// +/// Call once per window, after it opens. The view persists for the window's +/// life and carries no colour of its own, so a light/dark flip needs no +/// re-apply. +/// +/// [blurred]: gpui::WindowBackgroundAppearance::Blurred +#[cfg(target_os = "macos")] +#[expect( + unsafe_code, + reason = "reaching GPUI's NSView through its raw window handle; GPUI exposes no material API" +)] +pub fn configure_window_material(window: &gpui::Window) { + use objc2_app_kit::{NSView, NSVisualEffectMaterial, NSVisualEffectView}; + use objc2_foundation::MainThreadMarker; + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + + // Spelled out: gpui's inherent `Window::window_handle` returns its own + // `AnyWindowHandle` and would shadow the trait method here. + let Ok(handle) = HasWindowHandle::window_handle(window) else { + return; + }; + let RawWindowHandle::AppKit(handle) = handle.as_raw() else { + return; + }; + // AppKit access stays on the main thread; possessing the marker proves it. + let Some(_mtm) = MainThreadMarker::new() else { + return; + }; + + // SAFETY: `ns_view` is the live `NSView` GPUI created for this window and + // owns for its lifetime, so the borrow cannot outlive the view. We only + // read the view hierarchy and set a public AppKit property on a subview + // GPUI does not otherwise configure after creation. + unsafe { + let view = handle.ns_view.cast::().as_ref(); + let Some(content_view) = view.window().and_then(|window| window.contentView()) else { + return; + }; + for subview in content_view.subviews() { + if let Some(effect_view) = subview.downcast_ref::() { + effect_view.setMaterial(NSVisualEffectMaterial::WindowBackground); + } + } + } +} + +#[cfg(not(target_os = "macos"))] +pub fn configure_window_material(_window: &gpui::Window) {} diff --git a/crates/openlogi-gui/src/theme.rs b/crates/openlogi-gui/src/theme.rs index d9424da7..bbd00971 100644 --- a/crates/openlogi-gui/src/theme.rs +++ b/crates/openlogi-gui/src/theme.rs @@ -12,7 +12,10 @@ //! 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, FontWeight, Hsla, Pixels, Rgba, Styled, Window, hsla, px, relative, rgb}; +use gpui::{ + App, BoxShadow, FontWeight, Hsla, InteractiveElement, Pixels, Rgba, StatefulInteractiveElement, + Styled, Window, hsla, point, px, relative, rgb, +}; use gpui_component::{ActiveTheme as _, Theme, ThemeMode, ThemeRegistry}; use openlogi_core::config::Appearance; @@ -48,6 +51,14 @@ pub const CARD_GAP: f32 = 12.; /// Apple HIG / WCAG minimum contrast for normal text up to 17pt. const MIN_TEXT_CONTRAST: f32 = 4.5; +/// How much of the macOS window material [`Palette::backdrop`] lets through. +/// +/// Deliberately high. The point is a hint of the desktop moving behind a +/// surface that is still unmistakably the theme's own colour — and the muted +/// text ramp is normalised for contrast against the opaque colour, so a deeper +/// bleed would quietly undercut [`MIN_TEXT_CONTRAST`] over a bright wallpaper. +const BACKDROP_ALPHA: f32 = 0.9; + /// Fixed footprint of a device card in the Home gallery. Equal-width cards lay /// out in a horizontally scrollable row (centred when they fit, scrollable when /// they don't); `GALLERY_PHOTO_H` is the height of the device photo above the @@ -68,16 +79,58 @@ pub const GALLERY_PHOTO_H: f32 = 230.; pub struct Palette { /// Window background. pub bg: Hsla, + /// The main window's backdrop — the fill every screen sits on, below the + /// cards and panels that paint their own surfaces. + /// + /// On macOS this is [`Palette::bg`] at [`BACKDROP_ALPHA`], because that + /// window is backed by a real `NSVisualEffectView` (see + /// [`crate::platform::os::configure_window_material`]). The theme still + /// owns the colour — only the last tenth of it is the live material + /// underneath, which is what makes the window read as glass rather than as + /// a flat fill. + /// + /// A single translucent layer is the whole trick: everything above it + /// (cards, panels, popovers) is opaque, so no two translucent GPUI + /// surfaces ever stack and accumulate alpha into a muddy patch. + /// + /// Everywhere else this *is* `bg`: auxiliary windows and the non-macOS + /// main window are ordinary opaque surfaces. + pub backdrop: Hsla, /// Raised card / panel fill. pub surface: Hsla, - /// Card hover / armed fill. - pub surface_hover: Hsla, /// Hairline border between cards and surface. pub border: Hsla, + /// The hairline, raised — a hovered or otherwise emphasised edge. An alpha + /// tint rather than a second opaque value, so "emphasised" is the same + /// *step* on every surface; call sites used to reach for `text_muted` here, + /// which is a text weight and reads as an outline rather than an edge. + pub border_strong: Hsla, /// Foreground text. pub text_primary: Hsla, - /// De-emphasised labels / metadata. + /// De-emphasised labels / metadata. The contrast-normalised step (see + /// [`accessible_muted_text`]), so it is the *floor* for anything the user + /// has to read — the step below it is deliberately under AA. pub text_muted: Hsla, + /// Decorative marks only — leader lines, placeholder outlines, disabled + /// glyphs. Deliberately under AA: never body copy, and never the only + /// carrier of a meaning. + pub text_ghost: Hsla, + /// The one neutral interaction wash: hover, and any transient highlight. + /// + /// An alpha tint of the foreground rather than an opaque fill, so it + /// composites correctly over *any* surface it lands on (window, card, + /// nested card) instead of matching exactly one of them — and so hover + /// reads the same on all three without a per-surface variant. Reach for + /// this before inventing a fill: hover states had drifted into four + /// dialects before it existed. + pub wash: Hsla, + /// The same wash, deeper. Two jobs, both "neutral but more committed than + /// hover": an armed / highlighted resting state, and a recessed well such + /// as a meter track. + pub wash_strong: Hsla, + /// Keyboard focus ring. The theme's own `ring` token, so a hand-painted + /// control's focus treatment matches the framework widgets' beside it. + pub ring: Hsla, /// Corner radius for the bespoke card / panel surfaces. Derived from the /// active gpui-component theme radius (`cx.theme().radius`) so the /// hand-painted cards follow the Appearance → radius slider — which the old @@ -158,20 +211,37 @@ fn normalize_theme_text_contrast(theme: &mut Theme) { /// 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` ← `group_box` (content cards), while `surface_hover` keeps the -/// theme's interactive `secondary_hover` state. +/// - `bg` ← `background` (window), `surface` ← `group_box` (content cards). /// - `border`, `text_primary` ← `foreground`, `text_muted` ← `muted_foreground`. +/// +/// `text_ghost` fades `muted_foreground` toward whatever it is painted on +/// rather than picking its own colour. That keeps the ramp ordered by +/// construction — an alpha below 1 can only move a colour toward its +/// background — so no user-selected theme can invert it past `muted`, and it +/// inherits the AA normalisation already applied to `muted`. +/// +/// The washes and `border_strong` tint `foreground` instead, which is what lets +/// one value serve every surface: 6% of the text colour over a card and over +/// the window are different pixels but the same *step*. #[must_use] pub fn palette(cx: &App) -> Palette { let t = cx.theme(); Palette { bg: t.background, + backdrop: if cfg!(target_os = "macos") { + t.background.opacity(BACKDROP_ALPHA) + } else { + t.background + }, surface: t.group_box, - surface_hover: t.secondary_hover, border: t.border, + border_strong: t.foreground.opacity(0.2), text_primary: t.foreground, text_muted: t.muted_foreground, + text_ghost: t.muted_foreground.opacity(0.5), + wash: t.foreground.opacity(0.06), + wash_strong: t.foreground.opacity(0.1), + ring: t.ring, card_radius: t.radius * 1.5, control_radius: t.radius, } @@ -334,6 +404,78 @@ pub trait SelectableStyle: Styled + Sized { impl SelectableStyle for E {} +/// The neutral hover decision, in one place — the counterpart to +/// [`SelectableStyle`] for the *transient* half of interaction. +/// +/// The split is deliberate and is the whole point of the two-axis colour +/// system: an accent tint means "this is the chosen one" (a fact about state), +/// a neutral wash means "the pointer is here" (a fact about the pointer). A row +/// that is both keeps its accent fill and takes [`accent_tint_hover`] instead, +/// so the two axes never fight over the same pixel. +pub trait WashStyle: InteractiveElement + Sized { + /// The neutral wash under the pointer. + #[must_use] + fn hover_wash(self, pal: Palette) -> Self { + self.hover(move |style| style.bg(pal.wash)) + } +} + +impl WashStyle for E {} + +/// What separates a hand-painted `div` from a real control: a native cursor, +/// a tab stop, and a visible keyboard focus ring. +/// +/// Framework widgets ([`gpui_component::button::Button`] and friends) already +/// carry this. These methods are for the surfaces we paint ourselves — gallery +/// cards, mouse-model labels, key targets, theme swatches — which had none of +/// it and were reachable by mouse only. +/// +/// **Activation comes free.** gpui maps enter / space to an element's click +/// listeners while it is focused, so making the element focusable is the whole +/// job; `on_click` stays exactly as the caller wrote it. gpui also keeps the +/// focus handle for us, in element state under the element's id, so a control +/// needs no `FocusHandle` field of its own — it only needs an `.id(..)`. +pub trait ControlStyle: StatefulInteractiveElement + Styled + Sized { + /// Cursor, tab stop, and focus ring — the part every control wants, and + /// nothing that touches the element's fill. + /// + /// Fills stay separate because they are not universal: a neutral row wants + /// [`WashStyle::hover_wash`] and [`Self::press_wash`], a selectable one + /// wants [`accent_tint_hover`], and an element that paints its own colour + /// (a page dot, a peeking card) wants neither — a wash would overwrite the + /// very thing that identifies it. + #[must_use] + fn control(self, pal: Palette) -> Self { + self.cursor_default() + .tab_index(0) + .focus_visible(move |style| style.shadow(vec![focus_ring(pal)])) + } + + /// The pressed state, one step past [`WashStyle::hover_wash`]. + #[must_use] + fn press_wash(self, pal: Palette) -> Self { + self.active(move |style| style.bg(pal.wash_strong)) + } +} + +impl ControlStyle for E {} + +/// The focus ring: an outer glow, not a border. +/// +/// A border would resize the element the moment it takes focus, and these +/// controls sit in tight rows and grids where one pixel of growth reflows the +/// whole line. A shadow with no blur and a small spread draws the same ring +/// outside the bounds, follows the corner radius, and costs no layout. +fn focus_ring(pal: Palette) -> BoxShadow { + BoxShadow { + color: pal.ring.opacity(0.6), + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(2.), + inset: false, + } +} + /// The app's type ramp as semantic roles, so a heading is `.text_heading()` /// everywhere instead of each call site re-picking a `text_*` size and a /// `font_weight`. Sizes, weights, and line heights live here once — an diff --git a/crates/openlogi-gui/src/windows/add_device.rs b/crates/openlogi-gui/src/windows/add_device.rs index c1907e4e..25af7ac5 100644 --- a/crates/openlogi-gui/src/windows/add_device.rs +++ b/crates/openlogi-gui/src/windows/add_device.rs @@ -29,7 +29,7 @@ use openlogi_hid::{Click, PasskeyMethod, ReceiverSelector}; use crate::app_menu::{CloseWindow, Minimize, Zoom}; use crate::ipc_client::Command; use crate::state::AppState; -use crate::theme::{self, Palette, Typography as _}; +use crate::theme::{self, ControlStyle as _, Palette, Typography as _, WashStyle as _}; use crate::windows::{self, AuxWindow}; /// The pairing flow's current UI state. Mirrors the [`PairingUpdate`] stream. @@ -314,8 +314,9 @@ fn device_row(idx: usize, device: &FoundDevice, pal: Palette) -> impl IntoElemen .rounded(pal.control_radius) .border_1() .border_color(pal.border) - .cursor_pointer() - .hover(|s| s.bg(pal.surface_hover)) + .control(pal) + .press_wash(pal) + .hover_wash(pal) .child( div() .text_body() diff --git a/crates/openlogi-gui/src/windows/settings/appearance.rs b/crates/openlogi-gui/src/windows/settings/appearance.rs index 651c948e..eb0c860a 100644 --- a/crates/openlogi-gui/src/windows/settings/appearance.rs +++ b/crates/openlogi-gui/src/windows/settings/appearance.rs @@ -9,7 +9,7 @@ use super::{ Styled, Theme, ThemeColor, ThemeConfig, ThemeFilter, ThemeMode, ThemeRegistry, div, h_flex, px, rgb, theme, v_flex, }; -use crate::theme::Typography as _; +use crate::theme::{ControlStyle as _, Typography as _}; /// 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 @@ -183,7 +183,8 @@ fn mode_card( .id(id) .gap(px(6.)) .items_center() - .cursor_pointer() + .control(pal) + .press_wash(pal) .child(thumb) .child( h_flex() @@ -460,13 +461,14 @@ fn theme_card( .border_color(if selected { swatch.primary } else { pal.border }) .bg(pal.surface) .shadow_xs() - .cursor_pointer() + // No press wash: the card presses by dropping its shadow, below. + .control(pal) .hover(move |style| { let style = style.shadow_sm(); if selected { style } else { - style.border_color(pal.text_muted) + style.border_color(pal.border_strong) } }) .active(gpui::Styled::shadow_2xs) @@ -557,7 +559,8 @@ fn filter_chip( .rounded_full() .border_1() .text_caption() - .cursor_pointer() + .control(pal) + .press_wash(pal) .map(|this| { if selected { this.border_color(pal.text_primary) @@ -565,7 +568,7 @@ fn filter_chip( } else { this.border_color(pal.border) .text_color(pal.text_muted) - .hover(|h| h.border_color(pal.text_muted)) + .hover(|h| h.border_color(pal.border_strong)) } }) .child(label) diff --git a/crates/openlogi-gui/src/windows/settings/assets.rs b/crates/openlogi-gui/src/windows/settings/assets.rs index 133d7719..dd969832 100644 --- a/crates/openlogi-gui/src/windows/settings/assets.rs +++ b/crates/openlogi-gui/src/windows/settings/assets.rs @@ -1,6 +1,6 @@ //! Assets (device-image cache) settings page. -use crate::theme::Typography as _; +use crate::theme::{ControlStyle as _, Typography as _, WashStyle as _}; use std::time::Duration; use super::{ @@ -215,8 +215,9 @@ fn action_button( .border_1() .border_color(pal.border) .text_caption() - .cursor_pointer() - .hover(move |s| s.bg(pal.surface_hover)) + .control(pal) + .press_wash(pal) + .hover_wash(pal) .child(label) .on_click(move |_, _, cx| on_click(cx)) } diff --git a/crates/openlogi-gui/src/windows/settings/permissions.rs b/crates/openlogi-gui/src/windows/settings/permissions.rs index 6d7f9c49..fb3261f4 100644 --- a/crates/openlogi-gui/src/windows/settings/permissions.rs +++ b/crates/openlogi-gui/src/windows/settings/permissions.rs @@ -12,7 +12,7 @@ use super::{ }; #[cfg(any(target_os = "macos", target_os = "linux"))] use crate::platform::permissions; -use crate::theme::Typography as _; +use crate::theme::{ControlStyle as _, Typography as _, WashStyle as _}; #[cfg_attr( not(any(target_os = "macos", target_os = "linux")), @@ -192,8 +192,9 @@ fn permission_field( .border_1() .border_color(pal.border) .text_caption() - .cursor_pointer() - .hover(move |s| s.bg(pal.surface_hover)) + .control(pal) + .press_wash(pal) + .hover_wash(pal) .child(action_label) .on_click(move |_, _, cx| { // Accessibility must be prompted in the agent (it owns the From 65e2671bc362d9e427392b017ac88b34ed97cfb0 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 09:07:37 +0800 Subject: [PATCH 2/4] style(gui): take the type ramp and chrome down to AppKit's metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The colour tokens, the window material and the control affordances all landed without changing what the app looks like at rest, because none of them touched the values that actually carry the look. This does. Type: the ramp now *is* AppKit's text styles — title1 22, title2 17, headline / body 13, subheadline 11 — where it previously ran a rung larger and topped out at Bold. Leading ratios are untouched on purpose: native chrome is small text with roomy leading, so density comes from the size, not from crowding lines. Weight stops at SEMIBOLD, leaving hierarchy to the colour ramp. Chrome: an 80 px header read as a page masthead. 56 px header, 34 px footer, and one notch off each spacing token. Both `*_VERTICAL_RESERVE` constants are now derived from those values rather than a 224 px literal, so the reclaimed height goes to the device model instead of staying budgeted for chrome that shrank; the window's minimum height follows the same arithmetic down to 640. The pointer grid keeps its exact two-card fit at the 720 px minimum by taking the 8 px the narrower inset frees. Material: `BACKDROP_ALPHA` 0.9 → 0.8. At 0.9 the bleed was invisible against a dark desktop — the whole effect wasted. 0.8 still keeps the muted text ramp clear of its contrast floor over a bright wallpaper. The keyboard render test asserted its floor at a 500 px viewport, which is no longer short enough to reach it now that less height is reserved. The assertion is unchanged; its input is derived from the constants that decide the threshold, so it cannot rot the same way again. --- crates/openlogi-gui/src/app/detail.rs | 6 +- .../src/keyboard_model/function_row.rs | 14 +++- crates/openlogi-gui/src/main.rs | 4 +- crates/openlogi-gui/src/mouse_model/view.rs | 10 ++- crates/openlogi-gui/src/theme.rs | 71 ++++++++++++------- 5 files changed, 73 insertions(+), 32 deletions(-) diff --git a/crates/openlogi-gui/src/app/detail.rs b/crates/openlogi-gui/src/app/detail.rs index 8df3b519..fe35a2b7 100644 --- a/crates/openlogi-gui/src/app/detail.rs +++ b/crates/openlogi-gui/src/app/detail.rs @@ -272,9 +272,9 @@ fn pointer_tab( fn pointer_grid_card(card: impl IntoElement) -> impl IntoElement { // Two cards plus one 16 px gap fit exactly inside the 720 px window minimum - // after this tab's `SCREEN_PAD` (20 px) side inset, while still leaving a - // usable slider: 332·2 + 16 + 20·2 = 720. - div().min_w(px(332.)).flex_1().h_full().child(card) + // after this tab's `SCREEN_PAD` side inset, while still leaving a usable + // slider: 336·2 + 16 + 16·2 = 720. + div().min_w(px(336.)).flex_1().h_full().child(card) } /// Scrolling card: per-device native inversion and wheel-resolution controls. diff --git a/crates/openlogi-gui/src/keyboard_model/function_row.rs b/crates/openlogi-gui/src/keyboard_model/function_row.rs index 98436b72..65a70a24 100644 --- a/crates/openlogi-gui/src/keyboard_model/function_row.rs +++ b/crates/openlogi-gui/src/keyboard_model/function_row.rs @@ -88,7 +88,13 @@ const CALLOUT_BAND_H: f32 = 118.; /// Vertical chrome around the keyboard pane (header, tab strip, screen /// padding, footer) — the viewport height minus this and the callout band is /// what the render may occupy before it scales down to fit. -const KEYS_VERTICAL_RESERVE: f32 = 224.; +/// +/// Derived from the theme's chrome for the same reason the mouse model's +/// reserve is: a shorter header should widen the render, not go unspent. +const KEYS_VERTICAL_RESERVE: f32 = + theme::HEADER_H + theme::FOOTER_H + 2. * theme::SCREEN_PAD + KEYS_TAB_STRIP_H; +/// Height the detail tab strip occupies between the header and this tab body. +const KEYS_TAB_STRIP_H: f32 = 54.; /// Floor on the render height so a tiny window still shows a usable model. const KEYBOARD_MIN_IMG_H: f32 = 160.; const KEY_CALLOUT_W: f32 = 60.; @@ -1287,7 +1293,11 @@ mod tests { assert!((h - 700. * 1600. / 2760.).abs() < 0.01); // A short viewport shrinks the render instead of overflowing it. - let (w, h) = keyboard_render_size(Some(&g513), 500.); + // Derived, not a literal: the floor only engages below + // reserve + band + floor, and that threshold moves whenever the theme's + // chrome heights do. + let too_short = KEYS_VERTICAL_RESERVE + CALLOUT_BAND_H + KEYBOARD_MIN_IMG_H - 40.; + let (w, h) = keyboard_render_size(Some(&g513), too_short); assert_approx_eq(h, KEYBOARD_MIN_IMG_H); assert!((w - KEYBOARD_MIN_IMG_H * 2760. / 1600.).abs() < 0.01); diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index 200bcab9..840cfbdd 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -593,7 +593,9 @@ fn main_window_options(cx: &mut gpui::App) -> WindowOptions { // Min height keeps the buttons tab's mouse model above its scale floor // (`MODEL_MIN_H` + the chrome/padding reserve) so its side labels never // overlap; below this the model can't shrink further without crowding. - window_min_size: Some(Size::new(px(720.), px(680.))), + // The floor dropped with the chrome: a 56 px header and 34 px footer + // need 176 px of reserve, not 224. + window_min_size: Some(Size::new(px(720.), px(640.))), // Linux: transparent chrome so `AppView::render` can draw a client-side // `TitleBar` (the compositor declines server-side decorations and gpui's // fallback is unpainted). macOS/Windows keep their native titlebar. diff --git a/crates/openlogi-gui/src/mouse_model/view.rs b/crates/openlogi-gui/src/mouse_model/view.rs index 6634c549..eaa10d70 100644 --- a/crates/openlogi-gui/src/mouse_model/view.rs +++ b/crates/openlogi-gui/src/mouse_model/view.rs @@ -39,7 +39,15 @@ const HOTSPOT_DOT: f32 = 12.; /// Vertical space around the model that it can't draw into: the detail header /// and footer, plus the buttons-tab padding. The model scales to fit whatever /// viewport height remains. -const MODEL_VERTICAL_RESERVE: f32 = 224.; +/// +/// Derived rather than measured: the three terms the theme owns come from it, +/// so shrinking the chrome hands the model the space instead of leaving it +/// budgeted for a header that is no longer that tall. The residual is the tab +/// strip between them. +const MODEL_VERTICAL_RESERVE: f32 = + theme::HEADER_H + theme::FOOTER_H + 2. * theme::SCREEN_PAD + DETAIL_TAB_STRIP_H; +/// Height the detail tab strip occupies between the header and a tab body. +const DETAIL_TAB_STRIP_H: f32 = 54.; /// Floor for the scaled model height. Below this the evenly-slotted side labels /// (≈[`LABEL_H`] each) start to overlap; the window's minimum height is sized to /// keep the viewport above [`MODEL_VERTICAL_RESERVE`] + this. diff --git a/crates/openlogi-gui/src/theme.rs b/crates/openlogi-gui/src/theme.rs index bbd00971..523e7d20 100644 --- a/crates/openlogi-gui/src/theme.rs +++ b/crates/openlogi-gui/src/theme.rs @@ -33,8 +33,14 @@ pub const STATUS_OFFLINE: u32 = 0x006b_7280; pub const STATUS_DISABLED: u32 = 0x00ef_4444; /// Sizes that several components need to agree on. -pub const HEADER_H: f32 = 80.; -pub const FOOTER_H: f32 = 50.; +/// +/// Chrome heights are sized for a macOS toolbar and status bar rather than a +/// web banner: at 80px the header read as a page masthead, which is most of +/// why the window looked like a site instead of an app. Shrinking them frees +/// real estate the device model can use, so the two `*_VERTICAL_RESERVE` +/// constants that budget around them track these values. +pub const HEADER_H: f32 = 56.; +pub const FOOTER_H: f32 = 34.; /// Semantic spacing tokens (px), so surfaces that must agree share one value /// instead of each call site hand-picking a `p_*` / `gap_*` step. @@ -44,20 +50,21 @@ pub const FOOTER_H: f32 = 50.; /// two-column grid is sized against this exact value; see its card min-width). /// - `CARD_PAD` / `CARD_GAP` — a card's inner padding and its title-to-content /// gap, so every [`panel_card`](crate::app) reads the same. -pub const SCREEN_PAD: f32 = 20.; -pub const CARD_PAD: f32 = 16.; -pub const CARD_GAP: f32 = 12.; +pub const SCREEN_PAD: f32 = 16.; +pub const CARD_PAD: f32 = 12.; +pub const CARD_GAP: f32 = 10.; /// Apple HIG / WCAG minimum contrast for normal text up to 17pt. const MIN_TEXT_CONTRAST: f32 = 4.5; /// How much of the macOS window material [`Palette::backdrop`] lets through. /// -/// Deliberately high. The point is a hint of the desktop moving behind a -/// surface that is still unmistakably the theme's own colour — and the muted -/// text ramp is normalised for contrast against the opaque colour, so a deeper -/// bleed would quietly undercut [`MIN_TEXT_CONTRAST`] over a bright wallpaper. -const BACKDROP_ALPHA: f32 = 0.9; +/// High on purpose, but not so high the material stops reading: at 0.9 the +/// bleed was invisible against a dark desktop, which is the whole effect +/// wasted. The ceiling is legibility — the muted text ramp is normalised for +/// contrast against the *opaque* colour, so a deeper bleed than this would +/// quietly undercut [`MIN_TEXT_CONTRAST`] over a bright wallpaper. +const BACKDROP_ALPHA: f32 = 0.8; /// Fixed footprint of a device card in the Home gallery. Equal-width cards lay /// out in a horizontally scrollable row (centred when they fit, scrollable when @@ -478,53 +485,67 @@ fn focus_ring(pal: Palette) -> BoxShadow { /// The app's type ramp as semantic roles, so a heading is `.text_heading()` /// everywhere instead of each call site re-picking a `text_*` size and a -/// `font_weight`. Sizes, weights, and line heights live here once — an -/// Apple-HIG-inspired scale, more generous and higher-contrast than the raw -/// Tailwind steps it replaces — and every screen re-skins by editing this trait. +/// `font_weight`. Sizes, weights, and line heights live here once, and every +/// screen re-skins by editing this trait. +/// +/// The sizes are AppKit's own text styles — title1 22, title2 17, headline / +/// body 13, subheadline 11 — not a scale "inspired by" them. An earlier pass +/// deliberately ran a rung larger and heavier than HIG; the result read as a +/// web page rather than a Mac app, which is most of what "rough" meant. Native +/// chrome is small text with roomy leading, so the *leading ratios are +/// unchanged* — density comes from the size, not from crowding the lines. +/// +/// Weight tops out at SEMIBOLD, again as HIG does: hierarchy is carried by the +/// colour ramp ([`Palette::text_primary`] → `text_muted` → `text_ghost`), which +/// is a quieter signal than size and weight both shouting. /// /// Blanket-implemented for every [`Styled`] element, the same way /// [`SelectableStyle`] extends styling. Colour stays a separate axis (the caller /// still picks `pal.text_primary` / `text_muted`); this trait only fixes size, /// weight, and leading. pub trait Typography: Styled + Sized { - /// Page / dialog hero title (empty states, connection notices). The - /// heaviest, largest step — the one place Bold is used. + /// Page / dialog hero title (empty states, connection notices). AppKit + /// title1. #[must_use] fn text_title(self) -> Self { - self.text_size(px(26.)) - .font_weight(FontWeight::BOLD) + self.text_size(px(22.)) + .font_weight(FontWeight::SEMIBOLD) .line_height(relative(1.2)) } /// Screen / section heading — the Home title, a device name, a window's - /// primary heading. + /// primary heading. AppKit title2. #[must_use] fn text_heading(self) -> Self { - self.text_size(px(20.)) + self.text_size(px(17.)) .font_weight(FontWeight::SEMIBOLD) .line_height(relative(1.3)) } /// Card / group title and item names — a heading one rung down, sitting - /// inside a card rather than titling a screen. + /// inside a card rather than titling a screen. AppKit headline: body size + /// at semibold, so a card title aligns with the values under it instead of + /// stepping out of the grid. #[must_use] fn text_subheading(self) -> Self { - self.text_size(px(15.)) + self.text_size(px(13.)) .font_weight(FontWeight::SEMIBOLD) .line_height(relative(1.4)) } - /// Default body copy — control labels, descriptions, values. + /// Default body copy — control labels, descriptions, values. AppKit body, + /// which is also the system control size. #[must_use] fn text_body(self) -> Self { - self.text_size(px(15.)).line_height(relative(1.45)) + self.text_size(px(13.)).line_height(relative(1.45)) } /// De-emphasised metadata and helper text — the muted line under a label, - /// battery readouts, hints. Pair with `pal.text_muted`. + /// battery readouts, hints. AppKit subheadline. Pair with + /// `pal.text_muted`. #[must_use] fn text_caption(self) -> Self { - self.text_size(px(12.)).line_height(relative(1.4)) + self.text_size(px(11.)).line_height(relative(1.4)) } } From 5023d73069abeb6a38304bb38bedb3f4596154ab Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 10:14:06 +0800 Subject: [PATCH 3/4] feat(gui): tell a stale agent apart from an unreachable one, and label what was unlabelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five small things, four of them found by measuring rather than guessing. A stale agent reported as unreachable. The handshake already knows which side is old — `agent=13 gui=16` was in the log the whole time — but an older agent was folded into `ConnectFailure::Unreachable`, so the window said "Can't reach the background service … try reinstalling the app" while the socket was answering fine and the fix was to let the new agent take over. `AgentLink::OutdatedAgent` is the mirror of the `OutdatedGui` state that already existed, with its own frame and the same retry cadence. No wire change: the version comparison was already there. `moon.svg` was on disk but missing from `ACTION_ICONS`, so `Action::Sleep`'s icon silently failed to load in the binding picker — `load` only consults that table. Registered it, and added the test that would have caught it: the directory and the registry must agree. `include_bytes!` already covers the other direction, and `ActionRingIcon::ALL` covers the enum-driven icons; a file nobody registered was the uncovered case. `set_reduce_motion` was never called, so gpui's flag sat at its default and animations ran regardless of the system preference. Wired to `NSWorkspace` on macOS and GSettings on Linux, before the first window. Card radius `× 1.5` → `× 2`: at the theme's 6px control radius that moves cards from 9 to 12, far enough from the controls inside them that the nesting reads. Tooltips for the two controls that had no name at all: the carousel's prev/next chevrons and the DPI preset's remove button. The rest of the icon buttons already carry visible labels — the gap was two controls, not a category. --- crates/openlogi-gui/locales/da.yml | 5 ++ crates/openlogi-gui/locales/de.yml | 5 ++ crates/openlogi-gui/locales/el.yml | 5 ++ crates/openlogi-gui/locales/en.yml | 5 ++ crates/openlogi-gui/locales/es.yml | 5 ++ crates/openlogi-gui/locales/fi.yml | 5 ++ crates/openlogi-gui/locales/fr.yml | 5 ++ crates/openlogi-gui/locales/it.yml | 5 ++ crates/openlogi-gui/locales/ja.yml | 5 ++ crates/openlogi-gui/locales/ko.yml | 5 ++ crates/openlogi-gui/locales/nb.yml | 5 ++ crates/openlogi-gui/locales/nl.yml | 5 ++ crates/openlogi-gui/locales/pl.yml | 5 ++ crates/openlogi-gui/locales/pt-BR.yml | 5 ++ crates/openlogi-gui/locales/pt-PT.yml | 5 ++ crates/openlogi-gui/locales/ru.yml | 5 ++ crates/openlogi-gui/locales/sv.yml | 5 ++ crates/openlogi-gui/locales/zh-CN.yml | 5 ++ crates/openlogi-gui/locales/zh-HK.yml | 5 ++ crates/openlogi-gui/locales/zh-TW.yml | 5 ++ crates/openlogi-gui/src/app.rs | 6 +++ crates/openlogi-gui/src/app/status.rs | 18 +++++++ crates/openlogi-gui/src/app_assets.rs | 49 +++++++++++++++++++ .../openlogi-gui/src/components/carousel.rs | 8 +++ .../openlogi-gui/src/components/dpi_panel.rs | 1 + crates/openlogi-gui/src/ipc_client.rs | 33 +++++++++++-- crates/openlogi-gui/src/main.rs | 7 +++ crates/openlogi-gui/src/platform/os.rs | 45 +++++++++++++++++ crates/openlogi-gui/src/state.rs | 7 +++ crates/openlogi-gui/src/theme.rs | 8 ++- 30 files changed, 276 insertions(+), 6 deletions(-) diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 1a5805f6..a5b3e145 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Rul til venstre" "Scroll Right": "Rul til højre" "Add Device": "Tilføj enhed" +"Previous": "Forrige" +"Next": "Næste" +"Remove preset": "Fjern forudindstilling" "Add Device…": "Tilføj enhed…" "Put the device in pairing mode, then start searching.": "Sæt enheden i parringstilstand, og begynd derefter at søge." "Search for devices": "Søg efter enheder" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Opretter forbindelse til baggrundstjenesten…" "Can't reach the background service": "Kan ikke nå baggrundstjenesten" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi bliver ved med at prøve — hvis problemet fortsætter, så prøv at geninstallere appen." +"Waiting for the background service to update": "Venter på, at baggrundstjenesten opdateres" +"An older version of the service is still running — it will be replaced shortly.": "En ældre version af tjenesten kører stadig — den bliver udskiftet om lidt." "OpenLogi was updated": "OpenLogi er blevet opdateret" "This window is from the previous version — relaunch to finish the update.": "Dette vindue er fra den tidligere version — genstart appen for at fuldføre opdateringen." "Relaunch OpenLogi": "Genstart OpenLogi" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index 23729073..17c9fa72 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Nach links scrollen" "Scroll Right": "Nach rechts scrollen" "Add Device": "Gerät hinzufügen" +"Previous": "Zurück" +"Next": "Weiter" +"Remove preset": "Voreinstellung entfernen" "Add Device…": "Gerät hinzufügen…" "Put the device in pairing mode, then start searching.": "Versetze das Gerät in den Kopplungsmodus und starte dann die Suche." "Search for devices": "Nach Geräten suchen" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Verbindung zum Hintergrunddienst wird hergestellt…" "Can't reach the background service": "Hintergrunddienst nicht erreichbar" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi versucht es weiter – falls das Problem bestehen bleibt, installiere die App neu." +"Waiting for the background service to update": "Warten auf die Aktualisierung des Hintergrunddienstes" +"An older version of the service is still running — it will be replaced shortly.": "Eine ältere Version des Dienstes läuft noch — sie wird in Kürze ersetzt." "OpenLogi was updated": "OpenLogi wurde aktualisiert" "This window is from the previous version — relaunch to finish the update.": "Dieses Fenster stammt aus der vorherigen Version – starte die App neu, um das Update abzuschließen." "Relaunch OpenLogi": "OpenLogi neu starten" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index f8c7bf04..425466b3 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Κύλιση προς τα αριστερά" "Scroll Right": "Κύλιση προς τα δεξιά" "Add Device": "Προσθήκη συσκευής" +"Previous": "Προηγούμενο" +"Next": "Επόμενο" +"Remove preset": "Κατάργηση προεπιλογής" "Add Device…": "Προσθήκη συσκευής…" "Put the device in pairing mode, then start searching.": "Θέστε τη συσκευή σε λειτουργία αντιστοίχισης και μετά ξεκινήστε την αναζήτηση." "Search for devices": "Αναζήτηση συσκευών" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Σύνδεση με την υπηρεσία παρασκηνίου…" "Can't reach the background service": "Δεν είναι δυνατή η σύνδεση με την υπηρεσία παρασκηνίου" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "Το OpenLogi συνεχίζει τις προσπάθειες — αν το πρόβλημα επιμένει, δοκιμάστε να επανεγκαταστήσετε την εφαρμογή." +"Waiting for the background service to update": "Αναμονή για ενημέρωση της υπηρεσίας παρασκηνίου" +"An older version of the service is still running — it will be replaced shortly.": "Μια παλαιότερη έκδοση της υπηρεσίας εκτελείται ακόμη — θα αντικατασταθεί σύντομα." "OpenLogi was updated": "Το OpenLogi ενημερώθηκε" "This window is from the previous version — relaunch to finish the update.": "Αυτό το παράθυρο ανήκει στην προηγούμενη έκδοση — επανεκκινήστε την εφαρμογή για να ολοκληρωθεί η ενημέρωση." "Relaunch OpenLogi": "Επανεκκίνηση του OpenLogi" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 44a63e54..12f597d1 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Scroll Left" "Scroll Right": "Scroll Right" "Add Device": "Add Device" +"Previous": "Previous" +"Next": "Next" +"Remove preset": "Remove preset" "Add Device…": "Add Device…" "Put the device in pairing mode, then start searching.": "Put the device in pairing mode, then start searching." "Search for devices": "Search for devices" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Connecting to the background service…" "Can't reach the background service": "Can't reach the background service" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi keeps retrying — if this persists, try reinstalling the app." +"Waiting for the background service to update": "Waiting for the background service to update" +"An older version of the service is still running — it will be replaced shortly.": "An older version of the service is still running — it will be replaced shortly." "OpenLogi was updated": "OpenLogi was updated" "This window is from the previous version — relaunch to finish the update.": "This window is from the previous version — relaunch to finish the update." "Relaunch OpenLogi": "Relaunch OpenLogi" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index 7d62ba38..1d8d0014 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Desplazar a la izquierda" "Scroll Right": "Desplazar a la derecha" "Add Device": "Añadir dispositivo" +"Previous": "Anterior" +"Next": "Siguiente" +"Remove preset": "Quitar ajuste preestablecido" "Add Device…": "Añadir dispositivo…" "Put the device in pairing mode, then start searching.": "Pon el dispositivo en modo de emparejamiento y luego inicia la búsqueda." "Search for devices": "Buscar dispositivos" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Conectando con el servicio en segundo plano…" "Can't reach the background service": "No se puede conectar con el servicio en segundo plano" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi sigue reintentando: si el problema persiste, prueba a reinstalar la aplicación." +"Waiting for the background service to update": "Esperando a que se actualice el servicio en segundo plano" +"An older version of the service is still running — it will be replaced shortly.": "Todavía se está ejecutando una versión anterior del servicio — se reemplazará en breve." "OpenLogi was updated": "OpenLogi se ha actualizado" "This window is from the previous version — relaunch to finish the update.": "Esta ventana es de la versión anterior: reinicia la aplicación para completar la actualización." "Relaunch OpenLogi": "Reiniciar OpenLogi" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index dae3eca8..cc092b97 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Vieritä vasemmalle" "Scroll Right": "Vieritä oikealle" "Add Device": "Lisää laite" +"Previous": "Edellinen" +"Next": "Seuraava" +"Remove preset": "Poista esiasetus" "Add Device…": "Lisää laite…" "Put the device in pairing mode, then start searching.": "Aseta laite paritustilaan ja aloita sitten haku." "Search for devices": "Hae laitteita" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Yhdistetään taustapalveluun…" "Can't reach the background service": "Taustapalveluun ei saada yhteyttä" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi yrittää edelleen — jos ongelma jatkuu, kokeile asentaa sovellus uudelleen." +"Waiting for the background service to update": "Odotetaan taustapalvelun päivitystä" +"An older version of the service is still running — it will be replaced shortly.": "Palvelun vanhempi versio on yhä käynnissä — se korvataan pian." "OpenLogi was updated": "OpenLogi on päivitetty" "This window is from the previous version — relaunch to finish the update.": "Tämä ikkuna on edellisestä versiosta — käynnistä sovellus uudelleen viimeistelläksesi päivityksen." "Relaunch OpenLogi": "Käynnistä OpenLogi uudelleen" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index 646d0917..c19660bf 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Défiler vers la gauche" "Scroll Right": "Défiler vers la droite" "Add Device": "Ajouter un appareil" +"Previous": "Précédent" +"Next": "Suivant" +"Remove preset": "Supprimer le préréglage" "Add Device…": "Ajouter un appareil…" "Put the device in pairing mode, then start searching.": "Mettez l'appareil en mode association, puis lancez la recherche." "Search for devices": "Rechercher des appareils" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Connexion au service d'arrière-plan…" "Can't reach the background service": "Impossible de joindre le service d'arrière-plan" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi continue d'essayer — si le problème persiste, essayez de réinstaller l'application." +"Waiting for the background service to update": "En attente de la mise à jour du service en arrière-plan" +"An older version of the service is still running — it will be replaced shortly.": "Une version plus ancienne du service est encore active — elle sera remplacée sous peu." "OpenLogi was updated": "OpenLogi a été mis à jour" "This window is from the previous version — relaunch to finish the update.": "Cette fenêtre provient de l'ancienne version — relancez l'application pour terminer la mise à jour." "Relaunch OpenLogi": "Relancer OpenLogi" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index 512bfbaf..efbe7436 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Scorri a sinistra" "Scroll Right": "Scorri a destra" "Add Device": "Aggiungi dispositivo" +"Previous": "Precedente" +"Next": "Successivo" +"Remove preset": "Rimuovi preset" "Add Device…": "Aggiungi dispositivo…" "Put the device in pairing mode, then start searching.": "Metti il dispositivo in modalità di associazione, quindi avvia la ricerca." "Search for devices": "Cerca dispositivi" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Connessione al servizio in background…" "Can't reach the background service": "Impossibile raggiungere il servizio in background" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi continua a riprovare — se il problema persiste, prova a reinstallare l'app." +"Waiting for the background service to update": "In attesa dell'aggiornamento del servizio in background" +"An older version of the service is still running — it will be replaced shortly.": "È ancora in esecuzione una versione precedente del servizio — verrà sostituita a breve." "OpenLogi was updated": "OpenLogi è stato aggiornato" "This window is from the previous version — relaunch to finish the update.": "Questa finestra appartiene alla versione precedente — riavvia l'app per completare l'aggiornamento." "Relaunch OpenLogi": "Riavvia OpenLogi" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index 8d9d15ba..e16d6790 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "左にスクロール" "Scroll Right": "右にスクロール" "Add Device": "デバイスを追加" +"Previous": "前へ" +"Next": "次へ" +"Remove preset": "プリセットを削除" "Add Device…": "デバイスを追加…" "Put the device in pairing mode, then start searching.": "デバイスをペアリングモードにしてから検索を開始してください。" "Search for devices": "デバイスを検索" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "バックグラウンドサービスに接続中…" "Can't reach the background service": "バックグラウンドサービスに接続できません" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi は再試行を続けます — 問題が続く場合は、アプリの再インストールをお試しください。" +"Waiting for the background service to update": "バックグラウンドサービスの更新を待っています" +"An older version of the service is still running — it will be replaced shortly.": "古いバージョンのサービスがまだ実行中です — まもなく置き換えられます。" "OpenLogi was updated": "OpenLogi が更新されました" "This window is from the previous version — relaunch to finish the update.": "このウィンドウは旧バージョンのものです — 更新を完了するには再起動してください。" "Relaunch OpenLogi": "OpenLogi を再起動" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index 293f2bcd..433b1a2e 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "왼쪽으로 스크롤" "Scroll Right": "오른쪽으로 스크롤" "Add Device": "기기 추가" +"Previous": "이전" +"Next": "다음" +"Remove preset": "프리셋 제거" "Add Device…": "기기 추가…" "Put the device in pairing mode, then start searching.": "기기를 페어링 모드로 설정한 다음 검색을 시작하세요." "Search for devices": "기기 검색" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "백그라운드 서비스에 연결하는 중…" "Can't reach the background service": "백그라운드 서비스에 연결할 수 없습니다" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi가 계속 재시도합니다 — 문제가 지속되면 앱을 다시 설치해 보세요." +"Waiting for the background service to update": "백그라운드 서비스 업데이트를 기다리는 중" +"An older version of the service is still running — it will be replaced shortly.": "이전 버전의 서비스가 아직 실행 중입니다 — 곧 교체됩니다." "OpenLogi was updated": "OpenLogi가 업데이트되었습니다" "This window is from the previous version — relaunch to finish the update.": "이 창은 이전 버전의 창입니다 — 업데이트를 완료하려면 다시 실행하세요." "Relaunch OpenLogi": "OpenLogi 다시 실행" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index c2a992fd..3852c257 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Rull til venstre" "Scroll Right": "Rull til høyre" "Add Device": "Legg til enhet" +"Previous": "Forrige" +"Next": "Neste" +"Remove preset": "Fjern forhåndsinnstilling" "Add Device…": "Legg til enhet …" "Put the device in pairing mode, then start searching.": "Sett enheten i paringsmodus, og start deretter søket." "Search for devices": "Søk etter enheter" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Kobler til bakgrunnstjenesten…" "Can't reach the background service": "Får ikke kontakt med bakgrunnstjenesten" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi fortsetter å prøve — hvis problemet vedvarer, prøv å installere appen på nytt." +"Waiting for the background service to update": "Venter på at bakgrunnstjenesten oppdateres" +"An older version of the service is still running — it will be replaced shortly.": "En eldre versjon av tjenesten kjører fortsatt — den blir erstattet snart." "OpenLogi was updated": "OpenLogi har blitt oppdatert" "This window is from the previous version — relaunch to finish the update.": "Dette vinduet er fra den forrige versjonen — start appen på nytt for å fullføre oppdateringen." "Relaunch OpenLogi": "Start OpenLogi på nytt" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 8b93ce33..8e2f0db4 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Naar links scrollen" "Scroll Right": "Naar rechts scrollen" "Add Device": "Apparaat toevoegen" +"Previous": "Vorige" +"Next": "Volgende" +"Remove preset": "Voorinstelling verwijderen" "Add Device…": "Apparaat toevoegen…" "Put the device in pairing mode, then start searching.": "Zet het apparaat in koppelmodus en start daarna het zoeken." "Search for devices": "Zoek naar apparaten" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Verbinden met de achtergrondservice…" "Can't reach the background service": "Kan de achtergrondservice niet bereiken" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi blijft het proberen — als dit aanhoudt, installeer de app dan opnieuw." +"Waiting for the background service to update": "Wachten tot de achtergrondservice is bijgewerkt" +"An older version of the service is still running — it will be replaced shortly.": "Er draait nog een oudere versie van de service — die wordt binnenkort vervangen." "OpenLogi was updated": "OpenLogi is bijgewerkt" "This window is from the previous version — relaunch to finish the update.": "Dit venster is van de vorige versie — start de app opnieuw om de update te voltooien." "Relaunch OpenLogi": "OpenLogi opnieuw starten" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index 11898694..49909d72 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Przewiń w lewo" "Scroll Right": "Przewiń w prawo" "Add Device": "Dodaj urządzenie" +"Previous": "Poprzedni" +"Next": "Następny" +"Remove preset": "Usuń ustawienie wstępne" "Add Device…": "Dodaj urządzenie…" "Put the device in pairing mode, then start searching.": "Przełącz urządzenie w tryb parowania, a następnie rozpocznij wyszukiwanie." "Search for devices": "Wyszukaj urządzenia" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Łączenie z usługą działającą w tle…" "Can't reach the background service": "Nie można połączyć się z usługą działającą w tle" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi ponawia próby — jeśli problem nie ustąpi, spróbuj ponownie zainstalować aplikację." +"Waiting for the background service to update": "Oczekiwanie na aktualizację usługi w tle" +"An older version of the service is still running — it will be replaced shortly.": "Starsza wersja usługi nadal działa — wkrótce zostanie zastąpiona." "OpenLogi was updated": "OpenLogi został zaktualizowany" "This window is from the previous version — relaunch to finish the update.": "To okno pochodzi z poprzedniej wersji — uruchom aplikację ponownie, aby dokończyć aktualizację." "Relaunch OpenLogi": "Uruchom OpenLogi ponownie" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index ae44878f..aa54a07c 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Rolar para a Esquerda" "Scroll Right": "Rolar para a Direita" "Add Device": "Adicionar Dispositivo" +"Previous": "Anterior" +"Next": "Próximo" +"Remove preset": "Remover predefinição" "Add Device…": "Adicionar Dispositivo…" "Put the device in pairing mode, then start searching.": "Coloque o dispositivo em modo de pareamento e inicie a busca." "Search for devices": "Buscar dispositivos" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Conectando ao serviço em segundo plano…" "Can't reach the background service": "Não foi possível conectar ao serviço em segundo plano" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "O OpenLogi continua tentando — se o problema persistir, tente reinstalar o aplicativo." +"Waiting for the background service to update": "Aguardando a atualização do serviço em segundo plano" +"An older version of the service is still running — it will be replaced shortly.": "Uma versão mais antiga do serviço ainda está em execução — ela será substituída em breve." "OpenLogi was updated": "O OpenLogi foi atualizado" "This window is from the previous version — relaunch to finish the update.": "Esta janela é da versão anterior — reinicie o aplicativo para concluir a atualização." "Relaunch OpenLogi": "Reiniciar o OpenLogi" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index 19cbabad..2b95afed 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Deslocar para a esquerda" "Scroll Right": "Deslocar para a direita" "Add Device": "Adicionar dispositivo" +"Previous": "Anterior" +"Next": "Seguinte" +"Remove preset": "Remover predefinição" "Add Device…": "Adicionar dispositivo…" "Put the device in pairing mode, then start searching.": "Coloque o dispositivo em modo de emparelhamento e, em seguida, inicie a procura." "Search for devices": "Procurar dispositivos" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "A ligar ao serviço em segundo plano…" "Can't reach the background service": "Não foi possível contactar o serviço em segundo plano" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "O OpenLogi continua a tentar — se o problema persistir, experimente reinstalar a aplicação." +"Waiting for the background service to update": "A aguardar a atualização do serviço em segundo plano" +"An older version of the service is still running — it will be replaced shortly.": "Ainda está em execução uma versão mais antiga do serviço — será substituída em breve." "OpenLogi was updated": "O OpenLogi foi atualizado" "This window is from the previous version — relaunch to finish the update.": "Esta janela pertence à versão anterior — reinicie a aplicação para concluir a atualização." "Relaunch OpenLogi": "Reiniciar o OpenLogi" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 887cc624..bccd8138 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Прокрутить влево" "Scroll Right": "Прокрутить вправо" "Add Device": "Добавить устройство" +"Previous": "Назад" +"Next": "Вперёд" +"Remove preset": "Удалить пресет" "Add Device…": "Добавить устройство…" "Put the device in pairing mode, then start searching.": "Переведите устройство в режим сопряжения, затем начните поиск." "Search for devices": "Искать устройства" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Подключение к фоновой службе…" "Can't reach the background service": "Не удаётся связаться с фоновой службой" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi продолжает попытки — если проблема не исчезает, попробуйте переустановить приложение." +"Waiting for the background service to update": "Ожидание обновления фоновой службы" +"An older version of the service is still running — it will be replaced shortly.": "Всё ещё работает старая версия службы — она скоро будет заменена." "OpenLogi was updated": "OpenLogi обновлён" "This window is from the previous version — relaunch to finish the update.": "Это окно из предыдущей версии — перезапустите приложение, чтобы завершить обновление." "Relaunch OpenLogi": "Перезапустить OpenLogi" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index abe9e81c..d23184cb 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "Rulla vänster" "Scroll Right": "Rulla höger" "Add Device": "Lägg till enhet" +"Previous": "Föregående" +"Next": "Nästa" +"Remove preset": "Ta bort förinställning" "Add Device…": "Lägg till enhet…" "Put the device in pairing mode, then start searching.": "Sätt enheten i ihopparningsläge och börja sedan söka." "Search for devices": "Sök efter enheter" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "Ansluter till bakgrundstjänsten…" "Can't reach the background service": "Kan inte nå bakgrundstjänsten" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi fortsätter försöka — om problemet kvarstår, prova att installera om appen." +"Waiting for the background service to update": "Väntar på att bakgrundstjänsten uppdateras" +"An older version of the service is still running — it will be replaced shortly.": "En äldre version av tjänsten körs fortfarande — den ersätts inom kort." "OpenLogi was updated": "OpenLogi har uppdaterats" "This window is from the previous version — relaunch to finish the update.": "Det här fönstret är från den tidigare versionen — starta om appen för att slutföra uppdateringen." "Relaunch OpenLogi": "Starta om OpenLogi" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index e59a2384..30dfd75a 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "向左滚动" "Scroll Right": "向右滚动" "Add Device": "添加设备" +"Previous": "上一个" +"Next": "下一个" +"Remove preset": "移除预设" "Add Device…": "添加设备…" "Put the device in pairing mode, then start searching.": "先让设备进入配对模式,然后开始搜索。" "Search for devices": "搜索设备" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "正在连接后台服务…" "Can't reach the background service": "无法连接后台服务" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi 会持续重试——若问题持续,请尝试重新安装应用。" +"Waiting for the background service to update": "正在等待后台服务更新" +"An older version of the service is still running — it will be replaced shortly.": "旧版本的服务仍在运行 — 稍后会被替换。" "OpenLogi was updated": "OpenLogi 已更新" "This window is from the previous version — relaunch to finish the update.": "此窗口来自旧版本——重新启动应用以完成更新。" "Relaunch OpenLogi": "重新启动 OpenLogi" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index 75ec01c0..5a0c008e 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "向左捲動" "Scroll Right": "向右捲動" "Add Device": "新增裝置" +"Previous": "上一個" +"Next": "下一個" +"Remove preset": "移除預設" "Add Device…": "新增裝置…" "Put the device in pairing mode, then start searching.": "先讓裝置進入配對模式,然後開始搜尋。" "Search for devices": "搜尋裝置" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "正在連接背景服務…" "Can't reach the background service": "無法連接背景服務" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi 會持續重試——如問題持續,請嘗試重新安裝應用程式。" +"Waiting for the background service to update": "正在等待背景服務更新" +"An older version of the service is still running — it will be replaced shortly.": "舊版本的服務仍在執行 — 稍後會被取代。" "OpenLogi was updated": "OpenLogi 已更新" "This window is from the previous version — relaunch to finish the update.": "此視窗來自舊版本——請重新啟動應用程式以完成更新。" "Relaunch OpenLogi": "重新啟動 OpenLogi" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index f6329e35..a4822813 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -218,6 +218,9 @@ _version: 1 "Scroll Left": "向左捲動" "Scroll Right": "向右捲動" "Add Device": "新增裝置" +"Previous": "上一個" +"Next": "下一個" +"Remove preset": "移除預設" "Add Device…": "新增裝置…" "Put the device in pairing mode, then start searching.": "先讓裝置進入配對模式,然後開始搜尋。" "Search for devices": "搜尋裝置" @@ -265,6 +268,8 @@ _version: 1 "Connecting to the background service…": "正在連線背景服務…" "Can't reach the background service": "無法連線背景服務" "OpenLogi keeps retrying — if this persists, try reinstalling the app.": "OpenLogi 會持續重試——若問題持續,請嘗試重新安裝應用程式。" +"Waiting for the background service to update": "正在等待背景服務更新" +"An older version of the service is still running — it will be replaced shortly.": "舊版本的服務仍在執行 — 稍後會被取代。" "OpenLogi was updated": "OpenLogi 已更新" "This window is from the previous version — relaunch to finish the update.": "此視窗來自舊版本——請重新啟動應用程式以完成更新。" "Relaunch OpenLogi": "重新啟動 OpenLogi" diff --git a/crates/openlogi-gui/src/app.rs b/crates/openlogi-gui/src/app.rs index cbc82c54..4cb806fd 100644 --- a/crates/openlogi-gui/src/app.rs +++ b/crates/openlogi-gui/src/app.rs @@ -451,6 +451,12 @@ impl Render for AppView { window.set_window_title("OpenLogi"); return root.child(status::unreachable_body(pal)).into_any_element(); } + AgentLink::OutdatedAgent => { + window.set_window_title("OpenLogi"); + return root + .child(status::outdated_agent_body(pal)) + .into_any_element(); + } AgentLink::OutdatedGui => { window.set_window_title("OpenLogi"); return root diff --git a/crates/openlogi-gui/src/app/status.rs b/crates/openlogi-gui/src/app/status.rs index ec5d8dce..819852af 100644 --- a/crates/openlogi-gui/src/app/status.rs +++ b/crates/openlogi-gui/src/app/status.rs @@ -81,6 +81,24 @@ pub(super) fn unreachable_body(pal: Palette) -> AnyElement { .into_any_element() } +/// Whole-window frame when the agent answered with an *older* IPC protocol +/// than this process speaks: a stale agent binary still holds the socket, +/// usually because the app was updated while the old agent kept running. +/// +/// Deliberately not [`unreachable_body`]: the socket is answering, so the +/// "try reinstalling" advice there would send the user the wrong way. The +/// spawn retry and the agent-side takeover already drive the replacement, so +/// this frame only has to say what is happening. +pub(super) fn outdated_agent_body(pal: Palette) -> AnyElement { + notice_body( + tr!("Waiting for the background service to update"), + tr!("An older version of the service is still running — it will be replaced shortly."), + pal, + ) + .size_full() + .into_any_element() +} + /// Whole-window frame when the *agent* answered with a newer IPC protocol /// than this process speaks: the app bundle was updated while this window /// stayed open, and only a relaunch loads the new GUI. Without this frame the diff --git a/crates/openlogi-gui/src/app_assets.rs b/crates/openlogi-gui/src/app_assets.rs index f34ee0d2..2210a63a 100644 --- a/crates/openlogi-gui/src/app_assets.rs +++ b/crates/openlogi-gui/src/app_assets.rs @@ -59,6 +59,7 @@ const ACTION_ICONS: &[(&str, &[u8])] = &[ ("action-icons/list-checks.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/list-checks.svg"))), ("action-icons/lock.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/lock.svg"))), ("action-icons/monitor.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/monitor.svg"))), + ("action-icons/moon.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/moon.svg"))), ("action-icons/mouse-pointer-click.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/mouse-pointer-click.svg"))), ("action-icons/mouse.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/mouse.svg"))), ("action-icons/move.svg", include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons/move.svg"))), @@ -110,3 +111,51 @@ impl AssetSource for AppAssets { gpui_component_assets::Assets.list(path) } } + +#[cfg(test)] +mod tests { + #![allow( + clippy::expect_used, + reason = "a test that cannot read its own crate directory should fail loudly" + )] + + use super::*; + + /// Every vendored SVG is registered, and every registration has a file. + /// + /// The half that matters is disk → registry: `include_bytes!` already + /// fails the build for a registration with no file, but a *file* that + /// nobody registered compiles fine and then fails at runtime as a blank + /// icon, because `load` only consults this table. That is how + /// `Action::Sleep`'s `moon.svg` went missing from the binding picker. + /// + /// Not covered: a path literal that matches neither — the call sites spell + /// their icons inline and `Action` carries data, so there is no set to + /// iterate. `ActionRingIcon::ALL` covers the enum-driven family in + /// `action_ring_icons`. + #[test] + fn every_vendored_icon_is_registered() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/action-icons"); + let mut on_disk: Vec = std::fs::read_dir(dir) + .expect("action-icons directory should exist") + .map(|entry| entry.expect("readable dir entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "svg")) + .filter_map(|path| { + path.file_name() + .map(|name| format!("action-icons/{}", name.to_string_lossy())) + }) + .collect(); + on_disk.sort(); + + let mut registered: Vec = ACTION_ICONS + .iter() + .map(|(path, _)| (*path).to_owned()) + .collect(); + registered.sort(); + + assert_eq!( + on_disk, registered, + "action-icons/ and ACTION_ICONS disagree; an unregistered file renders as a blank icon" + ); + } +} diff --git a/crates/openlogi-gui/src/components/carousel.rs b/crates/openlogi-gui/src/components/carousel.rs index 6ffc0bed..b5dbd37b 100644 --- a/crates/openlogi-gui/src/components/carousel.rs +++ b/crates/openlogi-gui/src/components/carousel.rs @@ -262,6 +262,7 @@ impl Carousel { this.child(arrow( "carousel-prev", IconName::ChevronLeft, + tr!("Previous"), selected.saturating_sub(1), selected == 0, Size::Large, @@ -273,6 +274,7 @@ impl Carousel { this.child(arrow( "carousel-next", IconName::ChevronRight, + tr!("Next"), (selected + 1).min(len - 1), selected + 1 >= len, Size::Large, @@ -455,6 +457,7 @@ fn controls( t.child(arrow( "carousel-prev", IconName::ChevronLeft, + tr!("Previous"), selected.saturating_sub(1), selected == 0, Size::XSmall, @@ -473,6 +476,7 @@ fn controls( t.child(arrow( "carousel-next", IconName::ChevronRight, + tr!("Next"), (selected + 1).min(len - 1), selected + 1 >= len, Size::XSmall, @@ -515,6 +519,9 @@ fn side_slot( fn arrow( id: &'static str, icon: IconName, + // The only name this control has: a bare chevron says nothing to a tooltip + // or to anything reading the button. + label: SharedString, target: usize, disabled: bool, size: Size, @@ -524,6 +531,7 @@ fn arrow( .icon(icon) .ghost() .with_size(size) + .tooltip(label) .disabled(disabled) .when_some(on_select.filter(|_| !disabled), |this, handler| { this.on_click(move |_, window, cx| handler(&target, window, cx)) diff --git a/crates/openlogi-gui/src/components/dpi_panel.rs b/crates/openlogi-gui/src/components/dpi_panel.rs index ecd3ac56..2fcc5396 100644 --- a/crates/openlogi-gui/src/components/dpi_panel.rs +++ b/crates/openlogi-gui/src/components/dpi_panel.rs @@ -395,6 +395,7 @@ fn preset_chip(idx: usize, value: u32, active: bool, presets: &[u32], pal: Palet .xsmall() .ghost() .icon(IconName::Close) + .tooltip(tr!("Remove preset")) .on_click(move |_event, _window, cx| { let mut next = presets_for_remove.clone(); if idx < next.len() { diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs index 80660b90..6ad07441 100644 --- a/crates/openlogi-gui/src/ipc_client.rs +++ b/crates/openlogi-gui/src/ipc_client.rs @@ -66,6 +66,11 @@ pub enum GuiUpdate { /// updated on disk while this GUI kept running, and only a relaunch /// helps. Sent once per episode. OutdatedGui, + /// The mirror image: the agent answered with an *older* protocol, so a + /// stale agent binary is still holding the socket. Sent once per episode. + /// Distinct from [`Self::Unreachable`] because the socket is answering + /// fine — telling the user to reinstall would send them the wrong way. + OutdatedAgent, /// Result of an agent-owned standalone-light command. The typed failure /// reaches the GPUI state model instead of being reduced to a log line. LightCommandResult { @@ -188,6 +193,7 @@ async fn poll_loop( let mut last_delivery: Option = None; let mut notified_unreachable = false; let mut notified_outdated = false; + let mut notified_outdated_agent = false; let mut pacing = pacing::Pacing::new(poll_period, FAST_PHASE_MAX, started); let mut interval = ticker(None, STARTUP_POLL_PERIOD); loop { @@ -205,6 +211,7 @@ async fn poll_loop( last_delivery = Some(now); notified_unreachable = false; notified_outdated = false; + notified_outdated_agent = false; pacing.on_delivered(ready, now) } Ok(PollOutcome::NoAgent) => pacing.on_unreachable(now), @@ -215,6 +222,16 @@ async fn poll_loop( } pacing.on_newer_agent(now) } + Ok(PollOutcome::OlderAgent) => { + if !notified_outdated_agent { + notified_outdated_agent = true; + let _ = update_tx.send(GuiUpdate::OutdatedAgent); + } + // Same cadence as an absent agent: the socket answers, + // but nothing usable comes over it until the stale + // binary is replaced — which the spawn retry drives. + pacing.on_unreachable(now) + } Err(()) => { client = None; // drop the dead connection; reconnect next tick just_disconnected = true; @@ -465,10 +482,15 @@ fn agent_binary_path() -> Option { /// Why [`ensure`] couldn't produce a usable client. enum ConnectFailure { - /// Socket down, handshake failed, or the agent is *older* than us — in - /// every case the fix is an agent (re)start, which the spawn retry and - /// the agent-side takeover drive; keep retrying. + /// Socket down or the handshake failed outright. The fix is an agent + /// (re)start, which the spawn retry and the agent-side takeover drive; + /// keep retrying. Unreachable, + /// The agent is *older* than us: a stale binary still holds the socket. + /// Recovery is the same retry loop as [`Self::Unreachable`] — the agent + /// has to be replaced — but the *cause* is worth telling the user apart, + /// because "reinstall the app" is the wrong advice for it. + OlderAgent, /// The agent is *newer* than us: this GUI process is the stale side and /// only a relaunch helps. Surfaced to the user as [`GuiUpdate::OutdatedGui`]. NewerAgent, @@ -497,7 +519,7 @@ async fn ensure(client: &mut Option) -> Result<&AgentClient, Connec gui = PROTOCOL_VERSION, "agent IPC protocol is older — waiting for the agent to be replaced" ); - return Err(ConnectFailure::Unreachable); + return Err(ConnectFailure::OlderAgent); } Ok(version) => { warn!( @@ -525,6 +547,8 @@ enum PollOutcome { NoAgent, /// The agent speaks a newer protocol than this GUI. NewerAgent, + /// The agent speaks an older protocol than this GUI. + OlderAgent, } /// Poll status + inventory as one agent snapshot and push it. `Err` means a @@ -538,6 +562,7 @@ async fn poll( Ok(client) => client, Err(ConnectFailure::Unreachable) => return Ok(PollOutcome::NoAgent), Err(ConnectFailure::NewerAgent) => return Ok(PollOutcome::NewerAgent), + Err(ConnectFailure::OlderAgent) => return Ok(PollOutcome::OlderAgent), }; // Fetch status + inventory in one RPC so inventory readiness and the list // are interpreted from the same orchestrator state. diff --git a/crates/openlogi-gui/src/main.rs b/crates/openlogi-gui/src/main.rs index 840cfbdd..93e5e448 100644 --- a/crates/openlogi-gui/src/main.rs +++ b/crates/openlogi-gui/src/main.rs @@ -200,6 +200,10 @@ fn main() -> Result<()> { app.run(move |cx| { gpui_component::init(cx); theme::register_builtin_themes(cx); + // Before any window: gpui's animations read this flag, and a window + // that opened while it was still at its default would animate once + // against the user's stated preference. + platform::os::init_reduce_motion(cx); app_menu::install(cx); // Seed the Add Device window's initial state. Its buttons drive pairing @@ -439,6 +443,9 @@ fn main() -> Result<()> { Some(ipc_client::GuiUpdate::OutdatedGui) => { cx.update(|cx| set_agent_link(state::AgentLink::OutdatedGui, cx)); } + Some(ipc_client::GuiUpdate::OutdatedAgent) => { + cx.update(|cx| set_agent_link(state::AgentLink::OutdatedAgent, cx)); + } Some(ipc_client::GuiUpdate::LightCommandResult { key, request_id, diff --git a/crates/openlogi-gui/src/platform/os.rs b/crates/openlogi-gui/src/platform/os.rs index 10ac58d2..201bc357 100644 --- a/crates/openlogi-gui/src/platform/os.rs +++ b/crates/openlogi-gui/src/platform/os.rs @@ -124,3 +124,48 @@ pub fn configure_window_material(window: &gpui::Window) { #[cfg(not(target_os = "macos"))] pub fn configure_window_material(_window: &gpui::Window) {} + +/// Tell gpui whether the user asked the system to reduce motion. +/// +/// gpui's `with_animation` already honours `App::reduce_motion`, but nothing +/// ever *set* it, so the flag sat at its default and every animation ran +/// regardless of the system preference. Call once at startup, before the +/// window opens. +/// +/// A direct `window.request_animation_frame` for decorative motion is not +/// covered by that flag and must check `cx.reduce_motion()` itself. +#[cfg(target_os = "macos")] +pub fn init_reduce_motion(cx: &mut gpui::App) { + use objc2_app_kit::NSWorkspace; + + cx.set_reduce_motion(NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()); +} + +/// GNOME exposes the same preference through GSettings. Resolved off the UI +/// thread — it shells out — and applied when it lands; frames only ever read +/// gpui's in-memory flag. +#[cfg(target_os = "linux")] +pub fn init_reduce_motion(cx: &mut gpui::App) { + cx.spawn(async move |cx| { + let reduce = cx + .background_executor() + .spawn(async move { gnome_animations_disabled() }) + .await; + cx.update(|cx| cx.set_reduce_motion(reduce)).ok(); + }) + .detach(); +} + +#[cfg(target_os = "linux")] +fn gnome_animations_disabled() -> bool { + std::process::Command::new("gsettings") + .args(["get", "org.gnome.desktop.interface", "enable-animations"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .is_some_and(|value| value.trim().eq_ignore_ascii_case("false")) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] +pub fn init_reduce_motion(_cx: &mut gpui::App) {} diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 1de5c45f..adca30c3 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -89,6 +89,13 @@ pub enum AgentLink { /// running. Only relaunching helps; without this state the window would /// keep showing a live-looking but frozen UI. OutdatedGui, + /// The mirror image of [`Self::OutdatedGui`]: the agent answered with an + /// *older* protocol, so a stale agent binary still holds the socket. + /// + /// Its own state rather than [`Self::Unreachable`] because the two need + /// opposite advice — the socket is answering, so "try reinstalling" sends + /// the user the wrong way when the fix is to let the new agent take over. + OutdatedAgent, /// Connected and current: the agent's latest status snapshot. Ready(openlogi_agent_core::ipc::AgentStatus), } diff --git a/crates/openlogi-gui/src/theme.rs b/crates/openlogi-gui/src/theme.rs index 523e7d20..9a296488 100644 --- a/crates/openlogi-gui/src/theme.rs +++ b/crates/openlogi-gui/src/theme.rs @@ -143,10 +143,14 @@ pub struct Palette { /// hand-painted cards follow the Appearance → radius slider — which the old /// hard-coded `rounded_*` helpers (fixed px, blind to the slider) could not. /// - /// Scaled `× 1.5` above the base control radius so a card reads as rounder + /// Scaled `× 2` above the base control radius so a card reads as rounder /// than the small controls nested inside it — the concentric-corner /// relationship (outer radius > inner radius) that a single flat radius /// can't express. + /// + /// At the theme's default 6px control radius that puts cards at 12, the + /// step native apps use. The previous `× 1.5` landed on 9, close enough to + /// the controls inside that the nesting stopped reading. pub card_radius: Pixels, /// Corner radius for the small controls nested inside cards — chips, pills, /// segmented items, toggles. The base `cx.theme().radius`, i.e. the same @@ -249,7 +253,7 @@ pub fn palette(cx: &App) -> Palette { wash: t.foreground.opacity(0.06), wash_strong: t.foreground.opacity(0.1), ring: t.ring, - card_radius: t.radius * 1.5, + card_radius: t.radius * 2., control_radius: t.radius, } } From 0d1e57bffbca4e4c24518582fbbdeb66498a4f2d Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Fri, 14 Aug 2026 11:22:10 +0800 Subject: [PATCH 4/4] fix(gui): keep a protocol-mismatch frame from decaying into "unreachable" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mismatched protocol never sets `client`, so both mismatch states leave it at `None` — and the generic unreachable notice only checked that. Fifteen seconds after reporting the specialised frame, the fast phase elapsed and `GuiUpdate::Unreachable` talked over it with "try reinstalling the app", exactly the wrong advice while the socket is answering. Guard the notice on neither mismatch being live, and clear both when the socket actually goes away so a stale agent that then disappears still degrades to unreachable. This was not new: `OutdatedGui` has had the same hole since it was added. Fixing the mechanism fixes both directions. Read the GNOME reduce-motion preference synchronously, like the macOS arm. Resolving it in the background returned before `gsettings` did, so the first window — and its first animation — could be built while the flag still held its default, ignoring the preference for the moment it matters most. One subprocess before any window exists is not on a frame path. Cross-platform fallout from the same change: `ControlStyle` / `WashStyle` are only used by macOS-gated controls in the footer and the permissions page, so their imports are gated to match, and gpui's `App::update` returns the closure's value rather than a `Result` — the `.ok()` was macOS-invisible because that arm is `#[cfg]`-ed out there. --- crates/openlogi-gui/src/app/status.rs | 6 +++++- crates/openlogi-gui/src/ipc_client.rs | 17 ++++++++++++++++- crates/openlogi-gui/src/platform/os.rs | 19 ++++++++----------- .../src/windows/settings/permissions.rs | 6 +++++- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/crates/openlogi-gui/src/app/status.rs b/crates/openlogi-gui/src/app/status.rs index 819852af..1de499c3 100644 --- a/crates/openlogi-gui/src/app/status.rs +++ b/crates/openlogi-gui/src/app/status.rs @@ -11,7 +11,11 @@ use gpui_component::{ v_flex, }; -use crate::theme::{self, ControlStyle as _, FOOTER_H, Palette, Typography as _}; +use crate::theme::{self, FOOTER_H, Palette, Typography as _}; +// The footer's only control is the macOS Accessibility indicator; every other +// platform renders the footer as passive text. +#[cfg(target_os = "macos")] +use crate::theme::ControlStyle as _; /// Centered spinner over a muted one-line caption — the quiet "still working" /// body shared by the pre-connection frame and the scanning state, so the two diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs index 6ad07441..afc8fbd7 100644 --- a/crates/openlogi-gui/src/ipc_client.rs +++ b/crates/openlogi-gui/src/ipc_client.rs @@ -214,7 +214,15 @@ async fn poll_loop( notified_outdated_agent = false; pacing.on_delivered(ready, now) } - Ok(PollOutcome::NoAgent) => pacing.on_unreachable(now), + Ok(PollOutcome::NoAgent) => { + // The socket is absent now, so any protocol mismatch we + // reported describes an agent that is gone. Re-arm both + // notices: this is a fresh episode, and the generic + // unreachable notice below is right again. + notified_outdated = false; + notified_outdated_agent = false; + pacing.on_unreachable(now) + } Ok(PollOutcome::NewerAgent) => { if !notified_outdated { notified_outdated = true; @@ -241,8 +249,15 @@ async fn poll_loop( if let Some(cadence) = cadence { interval = apply_cadence(cadence, &pacing); } + // A protocol mismatch leaves `client` at `None` too, so without + // the two guards this would talk over the specialised frame + // once the fast phase elapsed — telling the user to reinstall + // while the socket is answering. Both are cleared again the + // moment the socket actually goes away (see `NoAgent`). if client.is_none() && !notified_unreachable + && !notified_outdated + && !notified_outdated_agent && now.duration_since(last_delivery.unwrap_or(started)) >= FAST_PHASE_MAX { notified_unreachable = true; diff --git a/crates/openlogi-gui/src/platform/os.rs b/crates/openlogi-gui/src/platform/os.rs index 201bc357..bb77f523 100644 --- a/crates/openlogi-gui/src/platform/os.rs +++ b/crates/openlogi-gui/src/platform/os.rs @@ -141,19 +141,16 @@ pub fn init_reduce_motion(cx: &mut gpui::App) { cx.set_reduce_motion(NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()); } -/// GNOME exposes the same preference through GSettings. Resolved off the UI -/// thread — it shells out — and applied when it lands; frames only ever read -/// gpui's in-memory flag. +/// GNOME exposes the same preference through GSettings. +/// +/// Read synchronously, like the macOS arm. It shells out, which is exactly the +/// kind of work the render path must never touch — but this runs once before +/// the first window exists, and resolving it in the background would let that +/// window (and its first animation) be created while the flag still held its +/// default, quietly ignoring the preference for the moment that matters most. #[cfg(target_os = "linux")] pub fn init_reduce_motion(cx: &mut gpui::App) { - cx.spawn(async move |cx| { - let reduce = cx - .background_executor() - .spawn(async move { gnome_animations_disabled() }) - .await; - cx.update(|cx| cx.set_reduce_motion(reduce)).ok(); - }) - .detach(); + cx.set_reduce_motion(gnome_animations_disabled()); } #[cfg(target_os = "linux")] diff --git a/crates/openlogi-gui/src/windows/settings/permissions.rs b/crates/openlogi-gui/src/windows/settings/permissions.rs index fb3261f4..03ff8f3e 100644 --- a/crates/openlogi-gui/src/windows/settings/permissions.rs +++ b/crates/openlogi-gui/src/windows/settings/permissions.rs @@ -12,7 +12,11 @@ use super::{ }; #[cfg(any(target_os = "macos", target_os = "linux"))] use crate::platform::permissions; -use crate::theme::{ControlStyle as _, Typography as _, WashStyle as _}; +use crate::theme::Typography as _; +// The only styled control on this page is the macOS "Open System Settings" +// row; the Linux page is plain text, so these would be unused imports there. +#[cfg(target_os = "macos")] +use crate::theme::{ControlStyle as _, WashStyle as _}; #[cfg_attr( not(any(target_os = "macos", target_os = "linux")),