fix: localize native tray menu
This commit is contained in:
parent
76bc312bb9
commit
fb4bb2a0c3
|
|
@ -7,7 +7,7 @@
|
||||||
### Рабочий контекст остаётся рядом — и хранится локально.
|
### Рабочий контекст остаётся рядом — и хранится локально.
|
||||||
|
|
||||||
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
|
Файлы, заметки, ссылки, материалы из браузера, активность и история работы
|
||||||
в одном расширяемом рабочем пространстве.
|
в одной расширяемой локальной среде.
|
||||||
|
|
||||||
[English](README.md) · **Русский**
|
[English](README.md) · **Русский**
|
||||||
|
|
||||||
|
|
@ -124,7 +124,7 @@ APPIMAGE_EXTRACT_AND_RUN=1 ./verstak-linux-x86_64-*.AppImage
|
||||||
|
|
||||||
## Фоновая работа и трей
|
## Фоновая работа и трей
|
||||||
|
|
||||||
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **Show Verstak**, чтобы вернуть окно, или **Quit**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
|
Закрытие главного окна не завершает Верстак: приложение остаётся в системном трее. Выберите **«Показать Верстак»**, чтобы вернуть окно, или **«Выйти»**, чтобы полностью завершить приложение. Благодаря этому напоминания Todo продолжают работать, пока окно скрыто.
|
||||||
|
|
||||||
### Проверка скачанного файла
|
### Проверка скачанного файла
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ type Manager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
config *Config
|
config *Config
|
||||||
configPath string
|
configPath string
|
||||||
|
onLanguageChanged func(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfigPath returns the default path for app settings.
|
// DefaultConfigPath returns the default path for app settings.
|
||||||
|
|
@ -230,13 +231,32 @@ func (m *Manager) UpdateLanguage(language string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
|
||||||
if m.config == nil {
|
if m.config == nil {
|
||||||
m.config = defaultConfig()
|
m.config = defaultConfig()
|
||||||
}
|
}
|
||||||
m.config.Language = language
|
m.config.Language = language
|
||||||
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
m.config.LastOpenedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
return m.saveLocked()
|
err := m.saveLocked()
|
||||||
|
handler := m.onLanguageChanged
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if handler != nil {
|
||||||
|
handler(language)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLanguageChangedHandler registers the shell callback invoked after a
|
||||||
|
// language preference has been durably updated.
|
||||||
|
func (m *Manager) SetLanguageChangedHandler(handler func(string)) {
|
||||||
|
if m == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.onLanguageChanged = handler
|
||||||
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSync replaces sync settings without changing unrelated app settings.
|
// UpdateSync replaces sync settings without changing unrelated app settings.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package appsettings
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,6 +75,34 @@ func TestUpdateLanguagePersistsAndPreservesUnrelatedSettings(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateLanguageNotifiesTheDesktopShellAfterPersisting(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
manager := NewManager(path)
|
||||||
|
if err := manager.Load(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var received []string
|
||||||
|
manager.SetLanguageChangedHandler(func(language string) {
|
||||||
|
received = append(received, language)
|
||||||
|
})
|
||||||
|
if err := manager.UpdateLanguage(LanguageRussian); err != nil {
|
||||||
|
t.Fatalf("UpdateLanguage: %v", err)
|
||||||
|
}
|
||||||
|
if got := manager.Get().Language; got != LanguageRussian {
|
||||||
|
t.Fatalf("persisted language = %q, want %q", got, LanguageRussian)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
|
||||||
|
t.Fatalf("language notifications = %#v, want [%q]", received, LanguageRussian)
|
||||||
|
}
|
||||||
|
if err := manager.UpdateLanguage("de"); err == nil {
|
||||||
|
t.Fatal("UpdateLanguage(de) succeeded, want validation failure")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(received, []string{LanguageRussian}) {
|
||||||
|
t.Fatalf("language notification fired after rejected update: %#v", received)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad_CorruptConfig(t *testing.T) {
|
func TestLoad_CorruptConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "config.json")
|
path := filepath.Join(dir, "config.json")
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
// Package tray owns the native tray menu wiring for the desktop shell.
|
// Package tray owns the native tray menu wiring for the desktop shell.
|
||||||
package tray
|
package tray
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
// MenuItem exposes click events from a native tray menu item.
|
// MenuItem exposes click events from a native tray menu item.
|
||||||
type MenuItem interface {
|
type MenuItem interface {
|
||||||
Clicked() <-chan struct{}
|
Clicked() <-chan struct{}
|
||||||
|
SetTitle(title string)
|
||||||
|
SetTooltip(tooltip string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend is the platform tray implementation.
|
// Backend is the platform tray implementation.
|
||||||
|
|
@ -23,11 +28,63 @@ type Actions struct {
|
||||||
Quit func()
|
Quit func()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Labels contains the user-visible text for one tray menu locale.
|
||||||
|
type Labels struct {
|
||||||
|
ShowTitle string
|
||||||
|
ShowTooltip string
|
||||||
|
QuitTitle string
|
||||||
|
QuitTooltip string
|
||||||
|
}
|
||||||
|
|
||||||
|
func englishLabels() Labels {
|
||||||
|
return Labels{
|
||||||
|
ShowTitle: "Show Verstak",
|
||||||
|
ShowTooltip: "Show the Verstak window",
|
||||||
|
QuitTitle: "Quit",
|
||||||
|
QuitTooltip: "Quit Verstak",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func russianLabels() Labels {
|
||||||
|
return Labels{
|
||||||
|
ShowTitle: "Показать Верстак",
|
||||||
|
ShowTooltip: "Показать окно Верстака",
|
||||||
|
QuitTitle: "Выйти",
|
||||||
|
QuitTooltip: "Завершить Верстак",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LabelsForPreference resolves the saved language preference for native tray UI.
|
||||||
|
// System locale values are intentionally passed in by the shell so this package
|
||||||
|
// stays independent of process environment and is deterministic in tests.
|
||||||
|
func LabelsForPreference(preference string, systemLocales ...string) Labels {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(preference), "ru") {
|
||||||
|
return russianLabels()
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(preference), "system") {
|
||||||
|
for _, locale := range systemLocales {
|
||||||
|
locale = strings.ToLower(strings.TrimSpace(locale))
|
||||||
|
if locale == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(locale, "ru") {
|
||||||
|
return russianLabels()
|
||||||
|
}
|
||||||
|
return englishLabels()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return englishLabels()
|
||||||
|
}
|
||||||
|
|
||||||
// Controller initializes one native tray and routes its menu actions.
|
// Controller initializes one native tray and routes its menu actions.
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
backend Backend
|
backend Backend
|
||||||
icon []byte
|
icon []byte
|
||||||
start sync.Once
|
start sync.Once
|
||||||
|
mu sync.RWMutex
|
||||||
|
labels Labels
|
||||||
|
show MenuItem
|
||||||
|
quit MenuItem
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a tray controller for one application process.
|
// New creates a tray controller for one application process.
|
||||||
|
|
@ -35,9 +92,22 @@ func New(backend Backend, icon []byte) *Controller {
|
||||||
return &Controller{
|
return &Controller{
|
||||||
backend: backend,
|
backend: backend,
|
||||||
icon: append([]byte(nil), icon...),
|
icon: append([]byte(nil), icon...),
|
||||||
|
labels: englishLabels(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetLabels updates the current and future native tray menu labels.
|
||||||
|
func (c *Controller) SetLabels(labels Labels) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.labels = labels
|
||||||
|
show, quit := c.show, c.quit
|
||||||
|
c.mu.Unlock()
|
||||||
|
applyLabels(show, quit, labels)
|
||||||
|
}
|
||||||
|
|
||||||
// Start registers the native tray without taking over the Wails event loop.
|
// Start registers the native tray without taking over the Wails event loop.
|
||||||
func (c *Controller) Start(actions Actions) {
|
func (c *Controller) Start(actions Actions) {
|
||||||
if c == nil || c.backend == nil {
|
if c == nil || c.backend == nil {
|
||||||
|
|
@ -47,8 +117,16 @@ func (c *Controller) Start(actions Actions) {
|
||||||
c.backend.Register(func() {
|
c.backend.Register(func() {
|
||||||
c.backend.SetIcon(c.icon)
|
c.backend.SetIcon(c.icon)
|
||||||
c.backend.SetTooltip("Verstak")
|
c.backend.SetTooltip("Verstak")
|
||||||
show := c.backend.AddMenuItem("Show Verstak", "Show the Verstak window")
|
c.mu.RLock()
|
||||||
quit := c.backend.AddMenuItem("Quit", "Quit Verstak")
|
labels := c.labels
|
||||||
|
c.mu.RUnlock()
|
||||||
|
show := c.backend.AddMenuItem(labels.ShowTitle, labels.ShowTooltip)
|
||||||
|
quit := c.backend.AddMenuItem(labels.QuitTitle, labels.QuitTooltip)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.show, c.quit = show, quit
|
||||||
|
labels = c.labels
|
||||||
|
c.mu.Unlock()
|
||||||
|
applyLabels(show, quit, labels)
|
||||||
if actions.Show != nil && show != nil {
|
if actions.Show != nil && show != nil {
|
||||||
go routeClicks(show.Clicked(), actions.Show)
|
go routeClicks(show.Clicked(), actions.Show)
|
||||||
}
|
}
|
||||||
|
|
@ -59,6 +137,17 @@ func (c *Controller) Start(actions Actions) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyLabels(show, quit MenuItem, labels Labels) {
|
||||||
|
if show != nil {
|
||||||
|
show.SetTitle(labels.ShowTitle)
|
||||||
|
show.SetTooltip(labels.ShowTooltip)
|
||||||
|
}
|
||||||
|
if quit != nil {
|
||||||
|
quit.SetTitle(labels.QuitTitle)
|
||||||
|
quit.SetTooltip(labels.QuitTooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop releases the native tray after Wails has begun application shutdown.
|
// Stop releases the native tray after Wails has begun application shutdown.
|
||||||
func (c *Controller) Stop() {
|
func (c *Controller) Stop() {
|
||||||
if c == nil || c.backend == nil {
|
if c == nil || c.backend == nil {
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,22 @@ import (
|
||||||
|
|
||||||
type fakeMenuItem struct {
|
type fakeMenuItem struct {
|
||||||
clicked chan struct{}
|
clicked chan struct{}
|
||||||
|
title string
|
||||||
|
tooltip string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *fakeMenuItem) Clicked() <-chan struct{} {
|
func (i *fakeMenuItem) Clicked() <-chan struct{} {
|
||||||
return i.clicked
|
return i.clicked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *fakeMenuItem) SetTitle(title string) {
|
||||||
|
i.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *fakeMenuItem) SetTooltip(tooltip string) {
|
||||||
|
i.tooltip = tooltip
|
||||||
|
}
|
||||||
|
|
||||||
type fakeBackend struct {
|
type fakeBackend struct {
|
||||||
icon []byte
|
icon []byte
|
||||||
tooltip string
|
tooltip string
|
||||||
|
|
@ -35,7 +45,7 @@ func (b *fakeBackend) SetTooltip(tooltip string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
|
func (b *fakeBackend) AddMenuItem(title, _ string) MenuItem {
|
||||||
item := &fakeMenuItem{clicked: make(chan struct{}, 1)}
|
item := &fakeMenuItem{clicked: make(chan struct{}, 1), title: title}
|
||||||
b.items[title] = item
|
b.items[title] = item
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
@ -89,3 +99,38 @@ func TestControllerStopsNativeTrayBackend(t *testing.T) {
|
||||||
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
|
t.Fatalf("backend quit calls = %d, want 1", backend.quitCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestControllerUsesAndUpdatesLocalizedLabels(t *testing.T) {
|
||||||
|
backend := &fakeBackend{items: make(map[string]*fakeMenuItem)}
|
||||||
|
controller := New(backend, []byte{1})
|
||||||
|
controller.SetLabels(LabelsForPreference("ru"))
|
||||||
|
controller.Start(Actions{})
|
||||||
|
|
||||||
|
show := backend.items["Показать Верстак"]
|
||||||
|
quit := backend.items["Выйти"]
|
||||||
|
if show == nil || quit == nil {
|
||||||
|
t.Fatalf("Russian tray menu = %#v", backend.items)
|
||||||
|
}
|
||||||
|
if show.tooltip != "Показать окно Верстака" || quit.tooltip != "Завершить Верстак" {
|
||||||
|
t.Fatalf("Russian tray tooltips = show:%q quit:%q", show.tooltip, quit.tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.SetLabels(LabelsForPreference("en"))
|
||||||
|
if show.title != "Show Verstak" || quit.title != "Quit" {
|
||||||
|
t.Fatalf("English tray menu after update = show:%q quit:%q", show.title, quit.title)
|
||||||
|
}
|
||||||
|
if show.tooltip != "Show the Verstak window" || quit.tooltip != "Quit Verstak" {
|
||||||
|
t.Fatalf("English tray tooltips after update = show:%q quit:%q", show.tooltip, quit.tooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLabelsForSystemRussianLocale(t *testing.T) {
|
||||||
|
labels := LabelsForPreference("system", "", "ru_RU.UTF-8", "en_US.UTF-8")
|
||||||
|
if labels.ShowTitle != "Показать Верстак" || labels.QuitTitle != "Выйти" {
|
||||||
|
t.Fatalf("system Russian labels = %#v", labels)
|
||||||
|
}
|
||||||
|
labels = LabelsForPreference("system", "C.UTF-8", "", "ru_RU.UTF-8")
|
||||||
|
if labels.ShowTitle != "Show Verstak" || labels.QuitTitle != "Quit" {
|
||||||
|
t.Fatalf("higher-priority system locale must win, got %#v", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,3 +39,15 @@ func (item systrayMenuItem) Clicked() <-chan struct{} {
|
||||||
}
|
}
|
||||||
return item.item.ClickedCh
|
return item.item.ClickedCh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (item systrayMenuItem) SetTitle(title string) {
|
||||||
|
if item.item != nil {
|
||||||
|
item.item.SetTitle(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (item systrayMenuItem) SetTooltip(tooltip string) {
|
||||||
|
if item.item != nil {
|
||||||
|
item.item.SetTooltip(tooltip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
7
main.go
7
main.go
|
|
@ -230,6 +230,13 @@ func main() {
|
||||||
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
|
app = api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, browserReceiver, debugEnabled)
|
||||||
app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
|
app.SetNotificationService(notifications.New(vaultService, api.NewNativeNotificationSender(), time.Now))
|
||||||
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
|
trayController := tray.New(tray.NewNativeBackend(), tray.DefaultIcon())
|
||||||
|
trayLabels := func(language string) tray.Labels {
|
||||||
|
return tray.LabelsForPreference(language, os.Getenv("LC_ALL"), os.Getenv("LC_MESSAGES"), os.Getenv("LANG"))
|
||||||
|
}
|
||||||
|
trayController.SetLabels(trayLabels(cfg.Language))
|
||||||
|
appSettingsMgr.SetLanguageChangedHandler(func(language string) {
|
||||||
|
trayController.SetLabels(trayLabels(language))
|
||||||
|
})
|
||||||
if browserReceiver != nil {
|
if browserReceiver != nil {
|
||||||
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
|
browserReceiverServer, err := browserreceiver.Start(browserreceiver.DefaultAddr, browserReceiver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue