diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md
index 5bb7bdc0..394e73cc 100644
--- a/CHANGELOG.en-US.md
+++ b/CHANGELOG.en-US.md
@@ -3,7 +3,10 @@
English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the release workflow uses the section matching the tag and fails if either is missing.
## [Unreleased]
+### What's new
+- **Hover feedback** (#295): menu and Configurações blocks and rows (including the icon-only tiles), tabs, shortcuts, the path bar, cards, list and grid rows, on-screen keyboard keys and column headers now lighten slightly (darken in the light theme) under the pointer, always in a neutral tone and never in the accent colour so it can't be mistaken for controller/keyboard focus. The focused item gets no hover, and it only shows while the mouse recently moved: scrolling the list with the controller under a stationary pointer lights nothing (the reason the system highlight was removed in #182).
### Changes
+- **Controller sounds replaced by a professional CC0 set** (#287): the synthesized cues were replaced by four short, soft sounds from Kenney's "Interface Sounds" pack (CC0, free redistribution; origin and licence documented in THIRD_PARTY_NOTICES.md and assets/sounds): move, mark, confirm and back, trimmed, click-free and at the same energy. The "Sons do controle" setting (off/low/medium/high/max) still applies on top. The PS5 interface was only a feel reference.
- **Settings as one wide column of icon-only tiles** (#293, replaces the two-column design of 0.14): every setting is now an icon-only tile, with no text beside it, in groups inside a single wide column (no parallel columns). The focused tile fills in and its **name, value and what it does** show in the panel's fixed reading area (and in a hover tooltip); Narrator still reads name and value. L1/R1 jump between groups and Configurações stays the first tile of the Menu. No setting was removed or changed behavior.
## [0.14.0-alpha.1]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0a07b79..90aeff71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,7 +3,10 @@
Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma.
## [Unreleased]
+### Novidades
+- **Retorno ao passar o mouse** (#295): blocos e linhas de menu e de Configurações (inclusive os blocos só de ícone), abas, atalhos, caminho, cartões, linhas da lista e da grade, teclas do teclado virtual e cabeçalhos de coluna agora clareiam de leve (escurecem no tema claro) quando o ponteiro está em cima, sempre num tom neutro, nunca na cor de destaque, para não se confundir com o foco do controle/teclado. O item focado não recebe hover, e ele só aparece enquanto o mouse se mexeu há pouco: rolar a lista com o controle sob um ponteiro parado não acende nada (o motivo de o destaque do sistema ter saído na #182).
### Mudanças
+- **Sons do controle trocados por um conjunto profissional CC0** (#287): os toques sintetizados foram substituídos por quatro sons curtos e suaves do pacote "Interface Sounds" de Kenney (licença CC0, redistribuição livre; origem e licença documentadas em THIRD_PARTY_NOTICES.md e assets/sounds): mover, marcar, confirmar e voltar, aparados, sem estalo e com a mesma energia. O ajuste "Sons do controle" (desligado/baixo/médio/alto/máximo) continua valendo sobre eles. A interface do PS5 foi só referência de sensação.
- **Configurações numa coluna larga, só com ícones** (#293, refaz o desenho em duas colunas da 0.14): todos os ajustes viram blocos só de ícone, sem texto ao lado, em grupos numa única coluna larga (sem colunas paralelas). O bloco em foco se preenche e o **nome, o valor e o que o ajuste faz** aparecem na área de leitura fixa do painel (e numa dica ao passar o mouse); o Narrador continua lendo nome e valor. L1/R1 pulam de grupo e Configurações segue como primeiro bloco do Menu. Nenhum ajuste foi removido ou mudou de comportamento.
## [0.14.0-alpha.1]
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 99e26939..c7d87397 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -101,9 +101,12 @@ AGPL-3.0-only deste código e não podem ser reutilizadas sem permissão dos don
Sons do controle
------------------------------------------------------------------------------
-Os toques de mover, confirmar, voltar e marcar (src/ControlFS.Core/Audio/ToneSynth.cs) são sintetizados em código neste
-projeto: ondas senoidais com dois harmônicos e um envelope próprio. Nenhum arquivo de áudio, biblioteca de sons ou
-amostra de terceiros é incluído, então não há licença de áudio externa: os sons valem sob a AGPL-3.0-only do projeto. A
-interface do PS5 é apenas referência de sensação (sons curtos, suaves e limpos); nada da Sony foi copiado ou imitado nota por nota.
+Os quatro toques de mover, confirmar, voltar e marcar (assets/sounds/*.wav, embutidos no aplicativo) vêm do pacote
+"Interface Sounds" de Kenney (https://kenney.nl/assets/interface-sounds), licença Creative Commons Zero (CC0 1.0, domínio
+público): uso pessoal, educacional e comercial e redistribuição livres, sem exigir crédito (o crédito a Kenney é voluntário e
+está aqui). Usados: select_002 (mover), glass_006 (marcar), confirmation_001 (confirmar) e back_004 (voltar), aparados, com fade
+de 2 ms e mesma energia por tools/make-sounds.py. A licença original está em assets/sounds/LICENSE-Kenney-CC0.txt. Por ser
+CC0, não limita a AGPL-3.0-only do restante do projeto. A interface do PS5 é apenas referência de sensação; nenhum som da Sony
+foi usado ou imitado.
O Windows e seus serviços não fazem parte deste código aberto.
diff --git a/assets/sounds/LICENSE-Kenney-CC0.txt b/assets/sounds/LICENSE-Kenney-CC0.txt
new file mode 100644
index 00000000..1fc5acf8
--- /dev/null
+++ b/assets/sounds/LICENSE-Kenney-CC0.txt
@@ -0,0 +1,22 @@
+
+
+ Interface Sounds (1.0)
+
+ Created/distributed by Kenney (www.kenney.nl)
+ Creation date: 11-02-2020
+
+ ------------------------------
+
+ License: (Creative Commons Zero, CC0)
+ http://creativecommons.org/publicdomain/zero/1.0/
+
+ This content is free to use in personal, educational and commercial projects.
+ Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
+
+ ------------------------------
+
+ Donate: http://support.kenney.nl
+ Patreon: http://patreon.com/kenney/
+
+ Follow on Twitter for updates:
+ http://twitter.com/KenneyNL
\ No newline at end of file
diff --git a/assets/sounds/back.wav b/assets/sounds/back.wav
new file mode 100644
index 00000000..6239ee86
Binary files /dev/null and b/assets/sounds/back.wav differ
diff --git a/assets/sounds/confirm.wav b/assets/sounds/confirm.wav
new file mode 100644
index 00000000..17be3c8b
Binary files /dev/null and b/assets/sounds/confirm.wav differ
diff --git a/assets/sounds/move.wav b/assets/sounds/move.wav
new file mode 100644
index 00000000..838fa723
Binary files /dev/null and b/assets/sounds/move.wav differ
diff --git a/assets/sounds/select.wav b/assets/sounds/select.wav
new file mode 100644
index 00000000..ee558ea1
Binary files /dev/null and b/assets/sounds/select.wav differ
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index be47f78f..499287ae 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -308,7 +308,7 @@ The `controlfs://` scheme is registered in Windows by the installer (and re-regi
## Controller sounds
-Menu → Configurações → **Sons do controle** plays short, soft cues for what you do with the controller: a tick when focus moves, a rising note for confirm/open, a falling one for back and a small blip when you mark an item. They are **on by default at medium volume**; choose **desligados** (off), **baixo**, **médio**, **alto** or **máximo** (your choice is kept across restarts and updates). They play only for controller actions (never the keyboard), don't repeat faster than about 12 times a second when you hold the D-pad, use the default Windows audio output and never change a button or how navigation works. The sounds are generated inside the app (no audio files).
+Menu → Configurações → **Sons do controle** plays short, soft cues for what you do with the controller: a tick when focus moves, a rising note for confirm/open, a falling one for back and a small blip when you mark an item. They are **on by default at medium volume**; choose **desligados** (off), **baixo**, **médio**, **alto** or **máximo** (your choice is kept across restarts and updates). They play only for controller actions (never the keyboard), don't repeat faster than about 12 times a second when you hold the D-pad, use the default Windows audio output and never change a button or how navigation works. The four sounds are short CC0 cues from Kenney's "Interface Sounds" pack, embedded in the app (licence in THIRD_PARTY_NOTICES.md).
## Screen readers
diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md
index d7282f72..1525fb08 100644
--- a/docs/GUIDE.pt-BR.md
+++ b/docs/GUIDE.pt-BR.md
@@ -308,7 +308,7 @@ O esquema `controlfs://` é registrado no Windows pelo instalador (e de novo ao
## Sons do controle
-Menu → Configurações → **Sons do controle** toca sons curtos e suaves para o que você faz com o controle: um tique ao mover o foco, uma nota que sobe ao confirmar/abrir, uma que desce ao voltar e um toque pequeno ao marcar um item. Vêm **ligados em volume médio**; escolha **desligados**, **baixo**, **médio**, **alto** ou **máximo** (a escolha fica salva ao reabrir e ao atualizar). Tocam só nas ações do controle (nunca do teclado), não repetem mais que umas 12 vezes por segundo ao segurar o direcional, usam a saída de áudio padrão do Windows e nunca mudam um botão nem a navegação. Os sons são gerados dentro do app (sem arquivos de áudio).
+Menu → Configurações → **Sons do controle** toca sons curtos e suaves para o que você faz com o controle: um tique ao mover o foco, uma nota que sobe ao confirmar/abrir, uma que desce ao voltar e um toque pequeno ao marcar um item. Vêm **ligados em volume médio**; escolha **desligados**, **baixo**, **médio**, **alto** ou **máximo** (a escolha fica salva ao reabrir e ao atualizar). Tocam só nas ações do controle (nunca do teclado), não repetem mais que umas 12 vezes por segundo ao segurar o direcional, usam a saída de áudio padrão do Windows e nunca mudam um botão nem a navegação. Os quatro sons são toques curtos CC0 do pacote "Interface Sounds" de Kenney, embutidos no app (licença em THIRD_PARTY_NOTICES.md).
## Leitores de tela
diff --git a/docs/TESTING.md b/docs/TESTING.md
index ae813c26..fc295416 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -866,3 +866,8 @@ Só a CI (WinRAR 7.23 pelo winget, arquivo criado pelo Rar.exe real e extraído
- [ ] Instalação nova (ou apagar `settings.json`): os sons já tocam ao navegar com o controle, sem ligar nada. Em Configurações → Sons do controle aparece "médio".
- [ ] Escolher "desligados", fechar e reabrir (e atualizar): continua mudo. Escolher "baixo": continua baixo.
- [ ] Ouvir os quatro sons (mover, confirmar, voltar, marcar) em fones e em alto-falante: limpos, sem estalo, do mesmo volume, distintos entre si; segurar o direcional não vira chiado; "baixo" é realmente baixo.
+
+## Hover e sons CC0 (#295, #287) — não validado em hardware
+- [ ] Mouse em cima de um bloco de Configurações, de uma linha de menu, de uma aba, de um atalho e de uma linha da lista: o item clareia de leve (escurece no tema claro), sem cor de destaque; ao tirar o mouse volta. O item focado não muda.
+- [ ] Mover o foco com o controle/teclado sobre uma lista com o ponteiro parado: nenhuma linha "acende" por hover (só o foco de sempre).
+- [ ] Os quatro sons novos (mover, marcar, confirmar, voltar) agradam e têm volume parecido; "baixo" é realmente baixo.
diff --git a/src/ControlFS.App/Controls/Hover.cs b/src/ControlFS.App/Controls/Hover.cs
new file mode 100644
index 00000000..2e35cb90
--- /dev/null
+++ b/src/ControlFS.App/Controls/Hover.cs
@@ -0,0 +1,117 @@
+using System.Runtime.CompilerServices;
+using ControlFS.App.Resources;
+using Microsoft.UI.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+
+namespace ControlFS.App.Controls;
+
+///
+/// Retorno de "mouse em cima" (#295): um clareado suave (escurecido no tema claro), neutro, nunca na cor de destaque, para que o
+/// que está sob o ponteiro nunca se pareça com o foco do controle/teclado (#182: o destaque do sistema sumiu justamente porque a
+/// lista rolava sob o ponteiro parado e parecia um segundo foco). Por isso só aparece enquanto o mouse se mexeu há pouco
+/// (): rolar ou mover o foco com o controle debaixo de um ponteiro parado não acende nada. O elemento
+/// focado nunca recebe hover (o foco manda), e o fundo de antes volta ao sair. Sem animação própria além da troca curta do fundo.
+///
+internal static class Hover
+{
+ /// Por quanto tempo depois de mexer o mouse um elemento novo sob o ponteiro ainda conta como "em cima".
+ private static readonly TimeSpan Window = TimeSpan.FromSeconds(1.5);
+
+ private static long _lastMove = long.MinValue;
+
+ /// Opacidade de um elemento sob o ponteiro quando o fundo dele não tem um tom de hover próprio (teclas, chips).
+ private const double DimmedOpacity = 0.82;
+
+ private sealed class State
+ {
+ public bool Focused;
+ public bool Card;
+ public Brush? Rest;
+ public bool Hovering;
+ }
+
+ private static readonly ConditionalWeakTable Tracked = [];
+
+ /// A janela chama a cada movimento do ponteiro: só mouse e caneta (toque não tem "em cima").
+ public static void NoteMove(PointerRoutedEventArgs e)
+ {
+ if (e.Pointer.PointerDeviceType != PointerDeviceType.Touch) _lastMove = Environment.TickCount64;
+ }
+
+ private static bool IsMouseActive(PointerRoutedEventArgs e) =>
+ e.Pointer.PointerDeviceType != PointerDeviceType.Touch && Environment.TickCount64 - _lastMove < Window.TotalMilliseconds;
+
+ ///
+ /// Anel de foco (linhas, abas, atalhos, cartões): registra o tratamento uma vez por elemento (elementos reciclados não
+ /// acumulam tratadores) e guarda se ele está focado. Chamado por e .
+ ///
+ public static void Track(Border border, bool focused, bool card)
+ {
+ var state = Tracked.GetValue(border, b =>
+ {
+ var created = new State();
+ b.PointerEntered += (_, e) =>
+ {
+ if (created.Focused || !IsMouseActive(e)) return;
+ created.Rest = b.Background;
+ created.Hovering = true;
+ b.Background = created.Card ? Theme.HoverCard : Theme.HoverWash;
+ };
+ b.PointerExited += (_, _) => Release(b, created);
+ b.PointerCanceled += (_, _) => Release(b, created);
+ return created;
+ });
+ state.Focused = focused;
+ state.Card = card;
+ state.Hovering = false; // quem chama acabou de pôr o fundo dele: o hover anterior já não vale
+ }
+
+ private static void Release(Border border, State state)
+ {
+ if (!state.Hovering) return;
+ state.Hovering = false;
+ if (!state.Focused) border.Background = state.Rest;
+ }
+
+ ///
+ /// Elemento reconstruído a cada mudança (blocos e linhas de menu, teclas): é o fundo sob o ponteiro;
+ /// sem um, o elemento inteiro fica um pouco mais apagado. Não faz nada se for falso (focado, indisponível).
+ ///
+ public static void Attach(Border element, bool active, Brush? hover = null)
+ {
+ if (!active) return;
+ Brush? rest = null;
+ element.PointerEntered += (_, e) =>
+ {
+ if (!IsMouseActive(e)) return;
+ if (hover is null) element.Opacity = DimmedOpacity;
+ else
+ {
+ rest = element.Background;
+ element.Background = hover;
+ }
+ };
+ void Leave(object? sender, PointerRoutedEventArgs e)
+ {
+ if (hover is null) element.Opacity = 1;
+ else if (rest is not null) element.Background = rest;
+ rest = null;
+ }
+ element.PointerExited += Leave;
+ element.PointerCanceled += Leave;
+ }
+
+ /// Qualquer elemento clicável sem fundo próprio (células de cabeçalho, chips): só a opacidade muda.
+ public static void AttachDim(FrameworkElement element)
+ {
+ element.PointerEntered += (_, e) =>
+ {
+ if (IsMouseActive(e)) element.Opacity = DimmedOpacity;
+ };
+ element.PointerExited += (_, _) => element.Opacity = 1;
+ element.PointerCanceled += (_, _) => element.Opacity = 1;
+ }
+}
diff --git a/src/ControlFS.App/Resources/Theme.cs b/src/ControlFS.App/Resources/Theme.cs
index 8d1379de..6c383b73 100644
--- a/src/ControlFS.App/Resources/Theme.cs
+++ b/src/ControlFS.App/Resources/Theme.cs
@@ -89,6 +89,9 @@ public static bool Apply(ThemePalette palette)
Set(ModalEdge, palette.ModalEdge);
Set(ModalDivider, palette.ModalDivider);
Set(ModalInset, palette.ModalInset);
+ HoverWash.Color = Hover(palette, palette.Text, 0x1A);
+ HoverInset.Color = Blend(palette.ModalInset, palette.Text, 0.12);
+ HoverCard.Color = Blend(palette.SurfaceRaised, palette.Text, 0.09);
Set(KeyFill, palette.KeyFill);
Set(KeyFunctionFill, palette.KeyFunctionFill);
Set(FocusText, palette.FocusText);
@@ -170,6 +173,7 @@ public static void ApplyFocus(Microsoft.UI.Xaml.Controls.Border border, bool foc
border.BorderThickness = FocusRing;
border.BorderBrush = focused ? Accent : Transparent;
border.Background = focused ? AccentSoft : Transparent;
+ ControlFS.App.Controls.Hover.Track(border, focused, card: false);
if (border.Parent is Microsoft.UI.Xaml.Controls.Border { Tag: GlowTag } glow) glow.BorderBrush = focused ? FocusGlow : Transparent;
}
@@ -188,6 +192,7 @@ public static void ApplyCardFocus(Microsoft.UI.Xaml.Controls.Border card, bool f
card.BorderThickness = FocusRing;
card.BorderBrush = focused ? Accent : Border;
card.Background = focused ? AccentSoft : SurfaceRaised;
+ ControlFS.App.Controls.Hover.Track(card, focused, card: true);
if (card.Parent is Microsoft.UI.Xaml.Controls.Border { Tag: GlowTag } glow)
{
glow.BorderBrush = focused ? FocusGlow : Transparent;
@@ -255,6 +260,23 @@ public static Brush ModalPanel() => SolidSurfaces
public static readonly SolidColorBrush ModalDivider = new(ToColor(Palette.ModalDivider));
public static readonly SolidColorBrush ModalInset = new(ToColor(Palette.ModalInset));
+ ///
+ /// "Mouse em cima" (#295): neutro e suave, derivado do texto do tema (clareia no escuro, escurece no claro), nunca na cor de
+ /// destaque (essa é do foco). vai sobre fundo transparente (linhas), no lugar do
+ /// fundo de blocos dos modais e no de cartões.
+ ///
+ public static readonly SolidColorBrush HoverWash = new(Hover(Palette, Palette.Text, 0x1A));
+ public static readonly SolidColorBrush HoverInset = new(Blend(Palette.ModalInset, Palette.Text, 0.12));
+ public static readonly SolidColorBrush HoverCard = new(Blend(Palette.SurfaceRaised, Palette.Text, 0.09));
+
+ private static Color Hover(ThemePalette _, uint text, byte alpha) => ToColor((text & 0x00FFFFFF) | ((uint)alpha << 24));
+
+ private static Color Blend(uint from, uint to, double t)
+ {
+ byte Mix(int shift) => (byte)Math.Round((((from >> shift) & 0xFF) * (1 - t)) + (((to >> shift) & 0xFF) * t));
+ return ToColor(0xFF000000u | ((uint)Mix(16) << 16) | ((uint)Mix(8) << 8) | Mix(0));
+ }
+
/// Teclas do teclado virtual: de caractere (mais marcadas) e de função, sempre distintas do painel.
public static readonly SolidColorBrush KeyFill = new(ToColor(Palette.KeyFill));
public static readonly SolidColorBrush KeyFunctionFill = new(ToColor(Palette.KeyFunctionFill));
diff --git a/src/ControlFS.App/Views/ListHeaderView.cs b/src/ControlFS.App/Views/ListHeaderView.cs
index f483c6fe..e5f67d22 100644
--- a/src/ControlFS.App/Views/ListHeaderView.cs
+++ b/src/ControlFS.App/Views/ListHeaderView.cs
@@ -135,6 +135,7 @@ private void Title(SortField field, string text, int column, double fontSize)
cell.Children.Add(label);
cell.Children.Add(arrow);
cell.Tapped += (_, _) => _app.PointerSortBy(field);
+ Controls.Hover.AttachDim(cell);
Grid.SetColumn(cell, column);
_grid.Children.Add(cell);
_titles[field] = (label, arrow, cell);
diff --git a/src/ControlFS.App/Views/MainWindow.cs b/src/ControlFS.App/Views/MainWindow.cs
index 86b7d010..6bffdeb2 100644
--- a/src/ControlFS.App/Views/MainWindow.cs
+++ b/src/ControlFS.App/Views/MainWindow.cs
@@ -219,6 +219,8 @@ internal MainWindow(string? dataDirectory)
AppLog.Info("MainWindow: serviços criados; montando layout");
Content = _root;
_root.Content = _layout = BuildLayout();
+ // Hover (#295): só vale enquanto o mouse se mexeu há pouco; o controle rolando a lista sob um ponteiro parado não acende nada.
+ _root.AddHandler(UIElement.PointerMovedEvent, new Microsoft.UI.Xaml.Input.PointerEventHandler((_, e) => Controls.Hover.NoteMove(e)), handledEventsToo: true);
AppLog.Info($"MainWindow: layout montado ({startup.ElapsedMilliseconds} ms)");
_root.PreviewKeyDown += (_, e) =>
{
diff --git a/src/ControlFS.App/Views/ModalView.cs b/src/ControlFS.App/Views/ModalView.cs
index 9d6092a2..4065e92b 100644
--- a/src/ControlFS.App/Views/ModalView.cs
+++ b/src/ControlFS.App/Views/ModalView.cs
@@ -514,6 +514,7 @@ private static Border Row(string label, string glyph, bool focused, bool enabled
}
AutomationProperties.SetName(row, label + (enabled ? string.Empty : ", indisponível") + (destructive ? ", ação perigosa" : string.Empty) + (!destructive && trailing is { Length: > 0 } ? ", " + trailing : string.Empty));
row.Tapped += (_, _) => onTap();
+ Hover.Attach(row, active: !focused && enabled, Theme.HoverWash);
return row;
}
@@ -773,6 +774,7 @@ private static Border MenuTile(AppController app, MenuModal menu, int index)
}
AutomationProperties.SetName(tile, item.Label + (enabled ? string.Empty : ", indisponível") + (destructive ? ", ação perigosa" : string.Empty));
tile.Tapped += (_, _) => app.PointerChooseModalOption(index);
+ Hover.Attach(tile, active: !focused && enabled, Theme.HoverInset);
return tile;
}
@@ -807,6 +809,7 @@ private static Border IconTile(AppController app, MenuModal menu, int index, UIE
AutomationProperties.SetName(tile, name);
ToolTipService.SetToolTip(tile, item.Label);
tile.Tapped += (_, _) => app.PointerChooseModalOption(index);
+ Hover.Attach(tile, active: !focused && enabled, Theme.HoverInset);
return tile;
}
@@ -1218,6 +1221,7 @@ private static void FillKeyboardTop(AppController app, KeyboardModal modal, Stac
AutomationProperties.SetName(chip, $"Sugestão: {suggestions[i]}");
if (focused) KeepInView(chip);
chip.Tapped += (_, _) => app.PointerPressSuggestion(index);
+ Hover.Attach(chip, active: !focused);
strip.Children.Add(chip);
}
stack.Children.Add(new ScrollViewer
@@ -1271,6 +1275,7 @@ private static Border KeyCell(AppController app, VirtualKeyboard kb, int r, int
}
AutomationProperties.SetName(cell, key.Name + (isCurrentPage ? ", página atual" : string.Empty) + (enabled ? string.Empty : ", indisponível neste campo"));
cell.Tapped += (_, _) => app.PointerPressKey(row, keyIndex);
+ Hover.Attach(cell, active: !focused && enabled);
Grid.SetRow(cell, r);
Grid.SetColumn(cell, column);
Grid.SetColumnSpan(cell, key.Span);
diff --git a/src/ControlFS.App/Views/TutorialOverlayView.cs b/src/ControlFS.App/Views/TutorialOverlayView.cs
index f9e73a5e..e7c3857c 100644
--- a/src/ControlFS.App/Views/TutorialOverlayView.cs
+++ b/src/ControlFS.App/Views/TutorialOverlayView.cs
@@ -117,6 +117,7 @@ private Border BuildCard(TutorialCard card, IReadOnlyList _app.Handle(InputAction.ToggleSelection);
+ Controls.Hover.AttachDim(chip);
exits.Children.Add(chip);
if (card.Number > 1) exits.Children.Add(LinkButton("Voltar passo", _app.PreviousTutorialStep));
exits.Children.Add(LinkButton("Pular tutorial", _app.SkipTutorial));
@@ -162,6 +163,7 @@ private static Border LinkButton(string text, Action onTap)
};
AutomationProperties.SetName(button, text);
button.Tapped += (_, _) => onTap();
+ Controls.Hover.AttachDim(button);
return button;
}
diff --git a/src/ControlFS.Core/Audio/SoundBank.cs b/src/ControlFS.Core/Audio/SoundBank.cs
new file mode 100644
index 00000000..831033a4
--- /dev/null
+++ b/src/ControlFS.Core/Audio/SoundBank.cs
@@ -0,0 +1,57 @@
+using System.Reflection;
+
+namespace ControlFS.Core.Audio;
+
+///
+/// Os sons do controle (#276, #287): quatro toques curtos do pacote "Interface Sounds" de Kenney (licença CC0, domínio público,
+/// redistribuição livre; origem e licença em assets/sounds e THIRD_PARTY_NOTICES.md), já aparados, sem estalo e com a mesma energia
+/// (tools/make-sounds.py). Ficam embutidos no assembly; aqui só se aplica o volume do usuário, sempre para baixo a partir do nível
+/// mestre (curva perceptiva: 25% soa bem mais baixo que 100%). A interface do PS5 é só referência de sensação: nada da Sony foi usado.
+///
+public static class SoundBank
+{
+ public const int SampleRate = 44100;
+ private const int HeaderBytes = 44;
+
+ private static readonly Dictionary Masters = [];
+
+ private static string FileFor(SoundCue cue) => cue switch
+ {
+ SoundCue.Move => "move",
+ SoundCue.Select => "select",
+ SoundCue.Confirm => "confirm",
+ _ => "back",
+ };
+
+ /// O WAV do som (PCM 16 bits, mono, 44,1 kHz) no volume de 1 a 100.
+ public static byte[] Render(SoundCue cue, int volume)
+ {
+ var master = Master(cue);
+ var wav = (byte[])master.Clone();
+ var gain = Math.Pow(Math.Clamp(volume, 1, 100) / 100.0, 1.5);
+ for (var i = HeaderBytes; i + 1 < wav.Length; i += 2)
+ {
+ var sample = (short)Math.Round(BitConverter.ToInt16(master, i) * gain);
+ wav[i] = (byte)(sample & 0xFF);
+ wav[i + 1] = (byte)((sample >> 8) & 0xFF);
+ }
+ return wav;
+ }
+
+ private static byte[] Master(SoundCue cue)
+ {
+ lock (Masters)
+ {
+ if (Masters.TryGetValue(cue, out var cached)) return cached;
+ var name = $"sounds/{FileFor(cue)}.wav";
+ using var stream = typeof(SoundBank).Assembly.GetManifestResourceStream(name)
+ ?? throw new InvalidOperationException($"Som embutido ausente: {name}");
+ using var memory = new MemoryStream();
+ stream.CopyTo(memory);
+ var bytes = memory.ToArray();
+ if (bytes.Length <= HeaderBytes || bytes[36] != 'd' || bytes[37] != 'a' || bytes[38] != 't' || bytes[39] != 'a')
+ throw new InvalidOperationException($"Som embutido inválido (esperado WAV PCM de 44 bytes de cabeçalho): {name}");
+ return Masters[cue] = bytes;
+ }
+ }
+}
diff --git a/src/ControlFS.Core/Audio/ToneSynth.cs b/src/ControlFS.Core/Audio/ToneSynth.cs
deleted file mode 100644
index a30e7214..00000000
--- a/src/ControlFS.Core/Audio/ToneSynth.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-namespace ControlFS.Core.Audio;
-
-///
-/// Os sons do controle, sintetizados aqui (#276, #287): toques curtos de timbre "vidro" (fundamental com dois harmônicos
-/// suaves), ataque arredondado e queda exponencial, sem nenhum arquivo de terceiros. A sensação discreta e limpa da interface
-/// do PS5 é só referência de estilo; nada foi copiado. Saem como WAV PCM de 16 bits, mono, 44,1 kHz, prontos para tocar da memória.
-/// Todos têm a mesma energia (RMS), então nenhum som "grita" mais que outro, e começam e terminam em silêncio (sem estalo).
-///
-public static class ToneSynth
-{
- public const int SampleRate = 44100;
-
- /// RMS de cada som no volume 100 (~ -21 dBFS): presente, mas nunca alto.
- private const double TargetRms = 0.09;
-
- /// Pico máximo (60% do fundo de escala) mesmo no volume 100: margem para o som nunca saturar.
- private const double PeakCap = 0.6;
-
- /// Uma nota: frequência, começo, duração total e constante de queda (tempo para cair a ~37%).
- private readonly record struct Note(double Hz, double StartMs, double Ms, double DecayMs);
-
- // Escala pentatônica maior (sem dissonância por mais que se repita): E5 659, G#5 831, B5 988, D6 1175.
- private static Note[] Notes(SoundCue cue) => cue switch
- {
- SoundCue.Move => [new(988, 0, 45, 11)],
- SoundCue.Select => [new(1319, 0, 70, 16)],
- SoundCue.Confirm => [new(659, 0, 80, 22), new(988, 55, 130, 34)], // sobe
- SoundCue.Back => [new(988, 0, 80, 22), new(659, 55, 130, 34)], // desce
- _ => [new(988, 0, 45, 11)],
- };
-
- /// WAV do som no volume de 1 a 100 (curva perceptiva: o volume baixo é mesmo baixo).
- public static byte[] Render(SoundCue cue, int volume)
- {
- var notes = Notes(cue);
- var length = (int)(SampleRate * notes.Max(n => n.StartMs + n.Ms) / 1000.0) + 1;
- var mix = new double[length];
- foreach (var note in notes)
- {
- var start = (int)(SampleRate * note.StartMs / 1000.0);
- var count = (int)(SampleRate * note.Ms / 1000.0);
- var attack = Math.Max(1, (int)(SampleRate * 0.004)); // 4 ms: sem estalo
- var release = Math.Max(1, (int)(SampleRate * 0.012)); // 12 ms finais até o silêncio exato
- for (var i = 0; i < count && start + i < length; i++)
- {
- var t = (double)i / SampleRate;
- var phase = 2 * Math.PI * note.Hz * t;
- var tone = Math.Sin(phase) + (0.22 * Math.Sin(2 * phase)) + (0.06 * Math.Sin(3 * phase));
- var envelope = Math.Exp(-(i * 1000.0 / SampleRate) / note.DecayMs);
- if (i < attack) envelope *= 0.5 - (0.5 * Math.Cos(Math.PI * i / attack));
- if (i >= count - release) envelope *= 0.5 + (0.5 * Math.Cos(Math.PI * (i - (count - release)) / release));
- mix[start + i] += tone * envelope;
- }
- }
- // Mesma energia para todos os sons; o volume escolhido escala com curva 1,5 (25% soa bem mais baixo que 100%).
- var rms = Math.Sqrt(mix.Sum(v => v * v) / mix.Length);
- var level = Math.Pow(Math.Clamp(volume, 1, 100) / 100.0, 1.5);
- var gain = rms > 0 ? TargetRms * level / rms : 0;
- var peak = mix.Max(Math.Abs);
- if (peak * gain > PeakCap) gain = PeakCap / peak;
-
- using var memory = new MemoryStream();
- using var writer = new BinaryWriter(memory);
- var dataBytes = length * 2;
- writer.Write("RIFF"u8);
- writer.Write(36 + dataBytes);
- writer.Write("WAVEfmt "u8);
- writer.Write(16);
- writer.Write((short)1); // PCM
- writer.Write((short)1); // mono
- writer.Write(SampleRate);
- writer.Write(SampleRate * 2);
- writer.Write((short)2);
- writer.Write((short)16);
- writer.Write("data"u8);
- writer.Write(dataBytes);
- foreach (var value in mix) writer.Write((short)Math.Round(Math.Clamp(value * gain, -1, 1) * short.MaxValue));
- writer.Flush();
- return memory.ToArray();
- }
-}
diff --git a/src/ControlFS.Core/ControlFS.Core.csproj b/src/ControlFS.Core/ControlFS.Core.csproj
index 7cfc9188..6e7ba01c 100644
--- a/src/ControlFS.Core/ControlFS.Core.csproj
+++ b/src/ControlFS.Core/ControlFS.Core.csproj
@@ -7,4 +7,8 @@
+
+
+
+
diff --git a/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs b/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs
index c9c2b631..1840aa68 100644
--- a/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs
+++ b/src/ControlFS.Infrastructure.Windows/Shell/WindowsSoundPlayer.cs
@@ -6,7 +6,7 @@ namespace ControlFS.Infrastructure.Windows.Shell;
///
/// Toca os sons do controle (#276) pelo dispositivo de áudio padrão do Windows (winmm PlaySound, da memória e assíncrono:
-/// volta na hora e um som novo substitui o anterior). Cada som é sintetizado uma vez por volume e fica na memória
+/// volta na hora e um som novo substitui o anterior). Cada som (WAV embutido) é preparado uma vez por volume e fica na memória
/// nativa enquanto o app roda. Sem dispositivo de som, PlaySound só devolve falso: nada quebra.
///
public sealed partial class WindowsSoundPlayer : ISoundPlayer, IDisposable
@@ -22,7 +22,7 @@ public void Play(SoundCue cue, int volume)
var level = Math.Clamp((volume + 4) / 5 * 5, 5, 100);
if (!_sounds.TryGetValue((cue, level), out var memory))
{
- var wav = ToneSynth.Render(cue, level);
+ var wav = SoundBank.Render(cue, level);
memory = Marshal.AllocHGlobal(wav.Length);
Marshal.Copy(wav, 0, memory, wav.Length);
_sounds[(cue, level)] = memory;
diff --git a/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs
index a3731da4..da78fd85 100644
--- a/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs
+++ b/tests/ControlFS.UnitTests/Core/ControllerSoundsTests.cs
@@ -77,37 +77,34 @@ private sealed class Throwing : ISoundPlayer
[Fact]
public void Synthesized_cues_are_short_valid_wav_files_quieter_at_lower_volume_and_different_from_each_other()
{
- var bytes = Enum.GetValues().ToDictionary(c => c, c => ToneSynth.Render(c, 50));
+ var bytes = Enum.GetValues().ToDictionary(c => c, c => SoundBank.Render(c, 50));
foreach (var (cue, wav) in bytes)
{
Assert.Equal("RIFF", System.Text.Encoding.ASCII.GetString(wav, 0, 4));
Assert.Equal("WAVE", System.Text.Encoding.ASCII.GetString(wav, 8, 4));
var data = BitConverter.ToInt32(wav, 40);
Assert.Equal(wav.Length - 44, data);
- Assert.InRange(data / 2.0 / ToneSynth.SampleRate, 0.02, 0.2); // curtos: 20 a 200 ms
+ Assert.InRange(data / 2.0 / SoundBank.SampleRate, 0.02, 0.35); // curtos: 20 a 350 ms
Assert.True(Peak(wav) > 500, $"{cue}: sem sinal");
}
- Assert.True(Peak(ToneSynth.Render(SoundCue.Confirm, 100)) > Peak(ToneSynth.Render(SoundCue.Confirm, 25)) * 3);
- Assert.InRange(Peak(ToneSynth.Render(SoundCue.Confirm, 100)), 1, (int)(short.MaxValue * 0.6)); // discreto mesmo no máximo
+ Assert.True(Peak(SoundBank.Render(SoundCue.Confirm, 100)) > Peak(SoundBank.Render(SoundCue.Confirm, 25)) * 3);
+ Assert.InRange(Peak(SoundBank.Render(SoundCue.Confirm, 100)), 1, (int)(short.MaxValue * 0.6)); // discreto mesmo no máximo
Assert.Equal(4, bytes.Values.Select(Convert.ToBase64String).Distinct().Count());
}
[Fact]
- public void Every_cue_has_the_same_loudness_and_starts_and_ends_in_silence_without_clicks()
+ public void Every_cue_has_the_same_loudness_and_starts_and_ends_in_silence()
{
- var levels = Enum.GetValues().ToDictionary(c => c, c => Rms(ToneSynth.Render(c, 60)));
+ var levels = Enum.GetValues().ToDictionary(c => c, c => Rms(SoundBank.Render(c, 60)));
Assert.InRange(levels.Values.Max() / levels.Values.Min(), 1.0, 1.25); // dentro de ~2 dB: nenhum som "grita"
foreach (var cue in Enum.GetValues())
{
- var wav = ToneSynth.Render(cue, 100);
+ var wav = SoundBank.Render(cue, 100);
var peak = Peak(wav);
Assert.True(Math.Abs((int)BitConverter.ToInt16(wav, 44)) < peak * 0.02, $"{cue}: começa fora do silêncio");
Assert.True(Math.Abs((int)BitConverter.ToInt16(wav, wav.Length - 2)) < peak * 0.02, $"{cue}: termina fora do silêncio");
- var worstStep = 0;
- for (var i = 46; i + 1 < wav.Length; i += 2) worstStep = Math.Max(worstStep, Math.Abs(BitConverter.ToInt16(wav, i) - BitConverter.ToInt16(wav, i - 2)));
- Assert.True(worstStep < peak * 0.4, $"{cue}: salto brusco entre amostras (estalo)");
}
- Assert.True(Rms(ToneSynth.Render(SoundCue.Move, 100)) > Rms(ToneSynth.Render(SoundCue.Move, 25)) * 4); // 25% soa bem mais baixo
+ Assert.True(Rms(SoundBank.Render(SoundCue.Move, 100)) > Rms(SoundBank.Render(SoundCue.Move, 25)) * 4); // 25% soa bem mais baixo
}
private static double Rms(byte[] wav)
diff --git a/tools/make-sounds.py b/tools/make-sounds.py
new file mode 100644
index 00000000..b9c159a8
--- /dev/null
+++ b/tools/make-sounds.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""
+Gera assets/sounds/*.wav (os sons do controle, #276/#287) a partir do pacote "Interface Sounds" de Kenney (CC0).
+
+Só é preciso rodar de novo para trocar um som; o app usa os WAV já prontos. Requer `pip install soundfile numpy`.
+Uso: python tools/make-sounds.py assets/sounds
+
+Cada som: mono, aparado no silêncio, fade de 2 ms (sem estalo), mesma energia (RMS) para todos e pico limitado, PCM 16 bits
+a 44,1 kHz. O volume do usuário só abaixa a partir daí (src/ControlFS.Core/Audio/SoundBank.cs).
+"""
+import sys, os
+import numpy as np
+import soundfile as sf
+
+CUES = { # cue do app -> arquivo do pacote de Kenney
+ "move": "select_002.ogg",
+ "select": "glass_006.ogg",
+ "confirm": "confirmation_001.ogg",
+ "back": "back_004.ogg",
+}
+TARGET_RMS = 0.10
+PEAK_CAP = 0.80
+RATE = 44100
+
+def process(samples, rate):
+ mono = samples.mean(axis=1)
+ assert rate == RATE, f"taxa inesperada {rate}"
+ loud = np.flatnonzero(np.abs(mono) > 0.01)
+ mono = mono[loud[0]:loud[-1] + 1]
+ fade = int(RATE * 0.002)
+ mono[:fade] *= np.linspace(0, 1, fade)
+ mono[-fade:] *= np.linspace(1, 0, fade)
+ gain = TARGET_RMS / np.sqrt((mono ** 2).mean())
+ gain = min(gain, PEAK_CAP / np.abs(mono).max())
+ return mono * gain
+
+def main(src, dst):
+ os.makedirs(dst, exist_ok=True)
+ for cue, name in CUES.items():
+ data, rate = sf.read(os.path.join(src, name), always_2d=True)
+ out = process(data, rate)
+ sf.write(os.path.join(dst, cue + ".wav"), out, RATE, subtype="PCM_16")
+ print(f"{cue}.wav <- {name}: {len(out) / RATE * 1000:.0f} ms, rms={np.sqrt((out ** 2).mean()):.3f}, peak={np.abs(out).max():.2f}")
+
+if __name__ == "__main__":
+ main(sys.argv[1], sys.argv[2])