diff --git a/Cargo.lock b/Cargo.lock index 00f65a412..10bbbd808 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 b05f4f50f..9dc11139f 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/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 1a5805f61..a5b3e1456 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 237290738..17c9fa727 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 f8c7bf047..425466b33 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 44a63e54b..12f597d18 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 7d62ba38f..1d8d0014e 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 dae3eca8f..cc092b97f 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 646d0917d..c19660bf2 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 512bfbaf1..efbe74363 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 8d9d15ba0..e16d6790e 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 293f2bcd7..433b1a2e9 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 c2a992fd6..3852c2579 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 8b93ce330..8e2f0db4d 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 118986943..49909d727 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 ae44878fc..aa54a07cf 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 19cbabad4..2b95afed8 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 887cc6243..bccd81389 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 abe9e81c1..d23184cb0 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 e59a23848..30dfd75a5 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 75ec01c07..5a0c008e4 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 f6329e353..a4822813a 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 cbc85aebe..4cb806fd0 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()) @@ -449,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 @@ -559,6 +567,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/detail.rs b/crates/openlogi-gui/src/app/detail.rs index 8df3b519b..fe35a2b75 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/app/home.rs b/crates/openlogi-gui/src/app/home.rs index 80ab1797e..7f0af5569 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 ce3521ca7..1de499c3f 100644 --- a/crates/openlogi-gui/src/app/status.rs +++ b/crates/openlogi-gui/src/app/status.rs @@ -12,6 +12,10 @@ use gpui_component::{ }; 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 @@ -81,6 +85,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 @@ -170,7 +192,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 7a7d91761..d3c7d99a5 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/app_assets.rs b/crates/openlogi-gui/src/app_assets.rs index f34ee0d22..2210a63a3 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/action_ring_panel.rs b/crates/openlogi-gui/src/components/action_ring_panel.rs index a735b4635..955b9e326 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 69f0453a7..e8eab38bc 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 e480b2eaf..1983e5b37 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 4b345f1be..8cdf57fde 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 25d945845..b5dbd37bc 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, |_, _| { @@ -260,6 +262,7 @@ impl Carousel { this.child(arrow( "carousel-prev", IconName::ChevronLeft, + tr!("Previous"), selected.saturating_sub(1), selected == 0, Size::Large, @@ -271,6 +274,7 @@ impl Carousel { this.child(arrow( "carousel-next", IconName::ChevronRight, + tr!("Next"), (selected + 1).min(len - 1), selected + 1 >= len, Size::Large, @@ -291,9 +295,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 +326,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 +381,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 +393,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 +408,7 @@ impl Carousel { selected, arrows, indicators, - accent, - dot_idle, + dot_colors, on_select.as_ref(), )) }) @@ -425,14 +416,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() @@ -444,6 +457,7 @@ fn controls( t.child(arrow( "carousel-prev", IconName::ChevronLeft, + tr!("Previous"), selected.saturating_sub(1), selected == 0, Size::XSmall, @@ -451,14 +465,18 @@ 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( "carousel-next", IconName::ChevronRight, + tr!("Next"), (selected + 1).min(len - 1), selected + 1 >= len, Size::XSmall, @@ -473,6 +491,7 @@ fn side_slot( el: Option, index: usize, frac: f32, + pal: Palette, on_select: Option, ) -> AnyElement { let base = div() @@ -486,7 +505,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)) @@ -500,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, @@ -509,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)) @@ -518,10 +541,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 +552,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 77dc3a83b..2fcc53964 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() @@ -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/components/light_panel.rs b/crates/openlogi-gui/src/components/light_panel.rs index 8d2f6857d..335678a2f 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 cb48ae37d..2a61d0995 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 277f98ee5..976a41e00 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/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs index 80660b90c..afc8fbd77 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,9 +211,18 @@ 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), + 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; @@ -215,6 +230,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; @@ -224,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; @@ -465,10 +497,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 +534,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 +562,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 +577,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/keyboard_model/function_row.rs b/crates/openlogi-gui/src/keyboard_model/function_row.rs index 07c95336d..65a70a246 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}; @@ -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.; @@ -529,9 +535,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 +605,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 +625,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 +678,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 +706,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 +728,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); } } } @@ -1272,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 95f0c059e..93e5e448a 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}; @@ -199,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 @@ -438,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, @@ -592,11 +600,24 @@ 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. 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 +648,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 c45f67a20..ad7635370 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 04c88454b..7db11a622 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 2c039f98d..eaa10d708 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.; @@ -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. @@ -258,7 +266,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 +281,7 @@ fn leader_canvas( &hotspots, &labels, highlight, + theme::palette(app), window, ); }, @@ -533,12 +545,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 +699,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 242b8a373..735ab2011 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 fdf753048..bb77f5233 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,110 @@ 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) {} + +/// 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. +/// +/// 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.set_reduce_motion(gnome_animations_disabled()); +} + +#[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 1de5c45ff..adca30c3f 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 d9424da7f..9a296488a 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; @@ -30,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. @@ -41,13 +50,22 @@ 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. +/// +/// 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 /// they don't); `GALLERY_PHOTO_H` is the height of the device photo above the @@ -68,25 +86,71 @@ 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 /// 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 @@ -158,21 +222,38 @@ 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, - card_radius: t.radius * 1.5, + 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 * 2., control_radius: t.radius, } } @@ -334,55 +415,141 @@ 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 -/// 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)) } } diff --git a/crates/openlogi-gui/src/windows/add_device.rs b/crates/openlogi-gui/src/windows/add_device.rs index c1907e4e6..25af7ac53 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 651c948e3..eb0c860a0 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 133d7719f..dd969832c 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 6d7f9c498..03ff8f3ed 100644 --- a/crates/openlogi-gui/src/windows/settings/permissions.rs +++ b/crates/openlogi-gui/src/windows/settings/permissions.rs @@ -13,6 +13,10 @@ use super::{ #[cfg(any(target_os = "macos", target_os = "linux"))] use crate::platform::permissions; 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")), @@ -192,8 +196,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